From 615839a04b20eaa25976404960440315f353db1f Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Wed, 3 Jun 2026 17:24:26 +0200 Subject: [PATCH 1/4] Count checkpoint "steps" by prompts, not file-modifying turns The session "steps" shown in the UI came from the CLI's StepCount, which only incremented on turns that modified files and reset to 0 after each condensation. This produced wrong and frequently-zero counts. - Bug 1: checkpoints written before any SaveStep ran (mid-turn/commit-only condensations) recorded 0. - Bug 2: `entire attach` never set the count, so it always wrote 0. - Counting change: derive the count from SessionTurnCount (every prompt, exec-mode safe) over a per-checkpoint window, with a deferred reset so back-to-back checkpoints report the same count instead of 0. Floored at 1. Decouples the combined-attribution gate (previously keyed off the now-floored checkpoints_count) onto a new SaveStepCount metadata field. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/attach.go | 30 ++++--- cmd/entire/cli/checkpoint/checkpoint.go | 13 ++- cmd/entire/cli/checkpoint/committed.go | 1 + cmd/entire/cli/lifecycle.go | 9 +++ cmd/entire/cli/lifecycle_test.go | 79 +++++++++++++++++++ cmd/entire/cli/session/state.go | 14 ++++ .../strategy/manual_commit_condensation.go | 24 +++++- .../manual_commit_condensation_test.go | 31 ++++++++ .../cli/strategy/manual_commit_hooks.go | 7 +- cmd/entire/cli/strategy/manual_commit_test.go | 3 + 10 files changed, 196 insertions(+), 15 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 1f6715f03..9f1a9ce5e 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -150,6 +150,15 @@ func runAttachSurfaceReviewErrors(cmd *cobra.Command, sessionID string, agentNam return err } +// attachStepCount returns the displayed "steps" count for an attached session. +// Attach writes exactly one checkpoint and has at most meta.FirstPrompt, so this +// floors at 1 — it must never render as "0 steps". SaveStepCount stays 0 (no +// SaveStep ran), keeping the combined-attribution gate conservative for this +// fallback session. +func attachStepCount(prompts []string) int { + return max(len(prompts), 1) +} + func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, opts attachOptions) error { // Initialize structured logger so logging.Warn/Info write to .entire/logs/ not stderr. if err := logging.Init(ctx, sessionID); err != nil { @@ -295,16 +304,17 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ } writeOpts := cpkg.WriteCommittedOptions{ - CheckpointID: checkpointID, - SessionID: sessionID, - Strategy: strategy.StrategyNameManualCommit, - Transcript: redactedTranscript, - Prompts: prompts, - AuthorName: author.Name, - AuthorEmail: author.Email, - Agent: ag.Type(), - Model: meta.Model, - TokenUsage: tokenUsage, + CheckpointID: checkpointID, + SessionID: sessionID, + Strategy: strategy.StrategyNameManualCommit, + Transcript: redactedTranscript, + Prompts: prompts, + CheckpointsCount: attachStepCount(prompts), + AuthorName: author.Name, + AuthorEmail: author.Email, + Agent: ag.Type(), + Model: meta.Model, + TokenUsage: tokenUsage, } if opts.Review { writeOpts.Kind = string(session.KindAgentReview) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 5bf71e886..9bc9babad 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -230,6 +230,12 @@ type WriteCommittedOptions struct { // CheckpointsCount is the number of checkpoints in this session CheckpointsCount int + // SaveStepCount is the number of SaveStep-recorded steps (shadow-branch + // commits) for this session. Distinct from CheckpointsCount (the displayed + // prompt count): this is the honest "did real checkpoint work happen" signal + // used to gate combined attribution. 0 means a commit-only / fallback session. + SaveStepCount int + // EphemeralBranch is the shadow branch name (for manual-commit strategy) EphemeralBranch string @@ -465,7 +471,12 @@ type CommittedMetadata struct { CreatedAt time.Time `json:"created_at"` Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD) CheckpointsCount int `json:"checkpoints_count"` - FilesTouched []string `json:"files_touched"` + // SaveStepCount is the number of SaveStep-recorded steps for this session. + // Honest "real checkpoint work happened" signal (0 = commit-only/fallback + // session), kept separate from the displayed CheckpointsCount prompt count. + // Added after CheckpointsCount stopped being a reliable did-SaveStep-run signal. + SaveStepCount int `json:"save_step_count,omitempty"` + FilesTouched []string `json:"files_touched"` // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") Agent types.AgentType `json:"agent,omitempty"` diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 68f7bfb87..4f36b1ef8 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -441,6 +441,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom CreatedAt: checkpointCreatedAt(opts), Branch: opts.Branch, CheckpointsCount: opts.CheckpointsCount, + SaveStepCount: opts.SaveStepCount, FilesTouched: opts.FilesTouched, Agent: opts.Agent, Model: opts.Model, diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 253b652dc..ad8fbb83d 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1113,6 +1113,7 @@ func persistEventMetadataToState(event *agent.Event, state *strategy.SessionStat } // Use hook-reported turn count if available (take max); otherwise // increment on each TurnEnd event to count turns ourselves. + prevTurnCount := state.SessionTurnCount if event.TurnCount > 0 { if event.TurnCount > state.SessionTurnCount { state.SessionTurnCount = event.TurnCount @@ -1120,6 +1121,14 @@ func persistEventMetadataToState(event *agent.Event, state *strategy.SessionStat } else if event.Type == agent.TurnEnd { state.SessionTurnCount++ } + // Deferred checkpoint-window reset: the first time a turn is counted after a + // checkpoint was written, re-anchor the window base to the turn count from + // before this turn so the current turn becomes the first prompt of the new + // window. Until then, back-to-back checkpoints keep reporting the same count. + if (event.TurnCount > 0 || event.Type == agent.TurnEnd) && state.PromptWindowResetPending { + state.PromptWindowBase = prevTurnCount + state.PromptWindowResetPending = false + } if event.ContextTokens > 0 { state.ContextTokens = event.ContextTokens } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index e3c504954..d8e7509ab 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -1959,3 +1959,82 @@ func TestAdoptInvestigateEnv_RejectsBadRunID(t *testing.T) { }) } } + +// promptWindow mirrors strategy.checkpointStepCount (unexported there): the +// displayed step count = SessionTurnCount - PromptWindowBase, floored at 1. +func promptWindow(s *strategy.SessionState) int { + if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 { + return w + } + return 1 +} + +// writeCheckpoint simulates what CondenseSession does to the window state: read +// the count, then set the deferred-reset flag (without zeroing the window). +func writeCheckpoint(s *strategy.SessionState) int { + n := promptWindow(s) + s.PromptWindowResetPending = true + return n +} + +// TestPromptWindowDeferredReset exercises the two product-required examples: +// (1) p1,p2,p3 -> A=3 then p4,p5 -> C=2, and (2) two checkpoints with no prompt +// in between report the same count (deferred reset). +func TestPromptWindowDeferredReset(t *testing.T) { + turn := func(s *strategy.SessionState) { + persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd}, s) + } + + s := &strategy.SessionState{} + + // p1,p2,p3 -> checkpoint A => 3 + turn(s) + turn(s) + turn(s) + if got := writeCheckpoint(s); got != 3 { + t.Fatalf("checkpoint A = %d, want 3", got) + } + + // Back-to-back: checkpoint B with no prompt in between => same as A (3), not 0. + if got := writeCheckpoint(s); got != 3 { + t.Fatalf("back-to-back checkpoint B = %d, want 3", got) + } + + // The next prompt re-anchors the window to start fresh. + turn(s) // p4: first prompt of the new window + if s.PromptWindowResetPending { + t.Fatalf("ResetPending should be cleared after the first post-checkpoint turn") + } + if s.PromptWindowBase != 3 { + t.Fatalf("PromptWindowBase = %d, want 3 (re-anchored to pre-turn count)", s.PromptWindowBase) + } + turn(s) // p5 + if got := writeCheckpoint(s); got != 2 { + t.Fatalf("checkpoint C = %d, want 2", got) + } +} + +// TestPromptWindowExecModeCumulativeTurnCount verifies the window derives +// correctly when turns arrive as a cumulative hook-reported TurnCount (exec-mode +// agents that never fire UserPromptSubmit/TurnStart), rather than as self-counted +// TurnEnd increments. +func TestPromptWindowExecModeCumulativeTurnCount(t *testing.T) { + exec := func(s *strategy.SessionState, cumulative int) { + persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd, TurnCount: cumulative}, s) + } + + s := &strategy.SessionState{} + + exec(s, 1) + exec(s, 2) + exec(s, 3) + if got := writeCheckpoint(s); got != 3 { + t.Fatalf("exec checkpoint A = %d, want 3", got) + } + + exec(s, 4) // re-anchors base to 3 + exec(s, 5) + if got := writeCheckpoint(s); got != 2 { + t.Fatalf("exec checkpoint B = %d, want 2", got) + } +} diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 4089ab386..f657ed999 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -246,6 +246,20 @@ type State struct { ContextTokens int `json:"context_tokens,omitempty"` ContextWindowSize int `json:"context_window_size,omitempty"` + // PromptWindowBase is the SessionTurnCount value at the start of the current + // checkpoint window. The number of prompts attributed to the next checkpoint is + // SessionTurnCount - PromptWindowBase (floored at 1 when written). It is only + // advanced (deferred reset) the next time a turn is counted after a checkpoint + // was written, so two checkpoints with no prompt between them report the same + // count. Zero-value safe on old state files: base 0 ⇒ window = SessionTurnCount, + // i.e. "all prompts so far" (correct first-checkpoint semantics). + PromptWindowBase int `json:"prompt_window_base,omitempty"` + + // PromptWindowResetPending indicates a checkpoint was just written and the + // window base must be re-anchored to the current SessionTurnCount the next time + // a turn is counted. Deferred so back-to-back checkpoints share a count. + PromptWindowResetPending bool `json:"prompt_window_reset_pending,omitempty"` + // Deprecated: TranscriptLinesAtStart is replaced by CheckpointTranscriptStart. // Kept for backward compatibility with existing state files. TranscriptLinesAtStart int `json:"transcript_lines_at_start,omitempty"` diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index ca91fa1ad..2224102ec 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -101,6 +101,19 @@ type condenseOpts struct { var redactSessionJSONLBytes = redact.JSONLBytes +// checkpointStepCount returns the number of user prompts attributed to the +// checkpoint being written: the turns counted since the current window's base. +// The base is re-anchored (deferred) the next time a turn is counted after a +// checkpoint write, so back-to-back checkpoints with no prompt between them share +// a count. Floored at 1 so we never record 0 (covers attach, a fast-path +// checkpoint before any turn, and exec-mode gaps where turns weren't counted). +func checkpointStepCount(s *SessionState) int { + if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 { + return w + } + return 1 +} + // CondenseSession condenses a session's shadow branch to permanent storage. // checkpointID is the 12-hex-char value from the Entire-Checkpoint trailer. // Metadata is stored at sharded path: // @@ -227,7 +240,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re Transcript: redactedTranscript, Prompts: sessionData.Prompts, FilesTouched: sessionData.FilesTouched, - CheckpointsCount: state.StepCount, + CheckpointsCount: checkpointStepCount(state), + SaveStepCount: state.StepCount, EphemeralBranch: shadowBranchName, AuthorName: authorName, AuthorEmail: authorEmail, @@ -261,6 +275,12 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re writeCommittedSpan.End() writeV1Duration := time.Since(writeV1Start) + // Deferred prompt-window reset: a checkpoint was written, so the window base + // must be re-anchored — but not now. We defer until the next counted turn (in + // persistEventMetadataToState) so two checkpoints with no prompt between them + // report the same count instead of the second showing 0. + state.PromptWindowResetPending = true + // Mirror the committed write to the v1 custom ref when opted in // (local-only, never pushed; failures are logged, not fatal). mirrorMetadataToV1CustomRef(ctx, repo) @@ -280,7 +300,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re return &CondenseResult{ CheckpointID: checkpointID, SessionID: state.SessionID, - CheckpointsCount: state.StepCount, + CheckpointsCount: checkpointStepCount(state), FilesTouched: sessionData.FilesTouched, Prompts: sessionData.Prompts, TotalTranscriptLines: sessionData.FullTranscriptLines, diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index 5c617cee7..cfadeaa40 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -554,3 +554,34 @@ func TestCondenseSession_TagsCheckpointSummaryWithHasInvestigation(t *testing.T) require.Equal(t, "0123456789ab", meta.InvestigateRunID, "per-session InvestigateRunID") require.Equal(t, "Why is checkout flaky?", meta.InvestigateTopic, "per-session InvestigateTopic") } + +// TestCheckpointStepCount covers the prompt-window math that produces the +// displayed "steps" count: SessionTurnCount - PromptWindowBase, floored at 1. +func TestCheckpointStepCount(t *testing.T) { + tests := []struct { + name string + sessionTurnCount int + promptWindowBase int + want int + }{ + {"first window of three prompts", 3, 0, 3}, + {"second window of two prompts", 5, 3, 2}, + {"no turns counted floors to 1", 0, 0, 1}, + // Back-to-back checkpoint: base not yet re-anchored, so it reports the same + // count as the prior checkpoint rather than 0. + {"back-to-back reports same as prior", 3, 0, 3}, + {"empty window floors to 1", 3, 3, 1}, + {"negative guard floors to 1", 2, 5, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &SessionState{ + SessionTurnCount: tt.sessionTurnCount, + PromptWindowBase: tt.promptWindowBase, + } + if got := checkpointStepCount(s); got != tt.want { + t.Errorf("checkpointStepCount() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 4a435fafd..ea283d2b8 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1072,15 +1072,18 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint( } // Collect union of files_touched from sessions that had real checkpoints (SaveStep ran). - // Sessions with checkpoints_count == 0 (e.g., commit-only sessions) use a fallback that + // Sessions with no SaveStep steps (e.g., commit-only sessions) use a fallback that // includes ALL committed files, which would incorrectly classify human-created files as agent work. + // Gate on SaveStepCount (the honest "SaveStep ran" signal), not CheckpointsCount — + // CheckpointsCount is now a prompt count floored at 1, so it's no longer 0 for these sessions. + // Old metadata lacks SaveStepCount → 0 → conservatively skipped, matching prior behavior. agentFiles := make(map[string]struct{}) for i := range len(summary.Sessions) { metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i) if readErr != nil || metadata == nil { continue } - if metadata.CheckpointsCount == 0 { + if metadata.SaveStepCount == 0 { continue // Skip sessions that used the filesTouched fallback } for _, f := range metadata.FilesTouched { diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index b1702d487..3bd0809a2 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -3558,6 +3558,9 @@ func TestCondenseSession_GeminiMultiCheckpoint(t *testing.T) { // what would happen after condensing checkpoint 1 state.CheckpointTranscriptStart = 2 // Start from message index 2 (the second user prompt) state.StepCount = 1 // Set to 1 (will be incremented to 2 by SaveStep) + // CheckpointsCount is now the prompt window (SessionTurnCount - PromptWindowBase), + // not StepCount. Simulate two counted turns so the assertion below still expects 2. + state.SessionTurnCount = 2 if err := s.saveSessionState(context.Background(), state); err != nil { t.Fatalf("failed to update session state: %v", err) } From 29d4607253c334a0deb05828663b005d0a71ec2e Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Wed, 3 Jun 2026 18:10:16 +0200 Subject: [PATCH 2/4] Address review: gate window reset on a real turn advance + fix comments - Only re-anchor the prompt window when SessionTurnCount actually increases, so a repeated/stale hook (same cumulative TurnCount) no longer clears the deferred reset early and break back-to-back checkpoint counts (Cursor bug). Adds a regression test. - Correct the checkpointStepCount doc (attach uses attachStepCount, not this path) and the WriteCommittedOptions.CheckpointsCount comment (now a prompt "steps" count, not a checkpoint count). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/checkpoint/checkpoint.go | 4 ++- cmd/entire/cli/lifecycle.go | 13 +++++---- cmd/entire/cli/lifecycle_test.go | 27 +++++++++++++++++++ .../strategy/manual_commit_condensation.go | 5 ++-- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 9bc9babad..32e58cb3a 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -227,7 +227,9 @@ type WriteCommittedOptions struct { // FilesTouched are files modified during the session FilesTouched []string - // CheckpointsCount is the number of checkpoints in this session + // CheckpointsCount is the displayed "steps" count for this session: the number + // of user prompts attributed to this checkpoint (floored at 1). Despite the + // historical name/JSON tag, it is no longer a count of checkpoints. CheckpointsCount int // SaveStepCount is the number of SaveStep-recorded steps (shadow-branch diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index ad8fbb83d..143a5a280 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -1121,11 +1121,14 @@ func persistEventMetadataToState(event *agent.Event, state *strategy.SessionStat } else if event.Type == agent.TurnEnd { state.SessionTurnCount++ } - // Deferred checkpoint-window reset: the first time a turn is counted after a - // checkpoint was written, re-anchor the window base to the turn count from - // before this turn so the current turn becomes the first prompt of the new - // window. Until then, back-to-back checkpoints keep reporting the same count. - if (event.TurnCount > 0 || event.Type == agent.TurnEnd) && state.PromptWindowResetPending { + // Deferred checkpoint-window reset: the first time the turn count actually + // advances after a checkpoint was written, re-anchor the window base to the + // count from before this turn so the current turn becomes the first prompt of + // the new window. Gate on a real advance (not just a TurnEnd / non-zero + // TurnCount) so a repeated or stale hook reporting the same cumulative count + // doesn't re-anchor early — that would make a later back-to-back checkpoint + // report 1 instead of matching the prior count. + if state.SessionTurnCount > prevTurnCount && state.PromptWindowResetPending { state.PromptWindowBase = prevTurnCount state.PromptWindowResetPending = false } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index d8e7509ab..23cb0b618 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -2038,3 +2038,30 @@ func TestPromptWindowExecModeCumulativeTurnCount(t *testing.T) { t.Fatalf("exec checkpoint B = %d, want 2", got) } } + +// TestPromptWindowStaleHookDoesNotResetEarly guards against a repeated/stale hook +// (same cumulative TurnCount, so the count doesn't actually advance) clearing the +// deferred reset early. If it did, a later back-to-back checkpoint would report 1 +// instead of matching the prior checkpoint's count. +func TestPromptWindowStaleHookDoesNotResetEarly(t *testing.T) { + exec := func(s *strategy.SessionState, cumulative int) { + persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd, TurnCount: cumulative}, s) + } + + s := &strategy.SessionState{} + exec(s, 1) + exec(s, 2) + exec(s, 3) + if got := writeCheckpoint(s); got != 3 { + t.Fatalf("checkpoint A = %d, want 3", got) + } + + // Stale hook: same cumulative count, no real advance — must not re-anchor. + exec(s, 3) + if !s.PromptWindowResetPending { + t.Fatalf("stale hook should not clear ResetPending") + } + if got := writeCheckpoint(s); got != 3 { + t.Fatalf("back-to-back checkpoint B after stale hook = %d, want 3", got) + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 2224102ec..9e6a6a2dd 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -105,8 +105,9 @@ var redactSessionJSONLBytes = redact.JSONLBytes // checkpoint being written: the turns counted since the current window's base. // The base is re-anchored (deferred) the next time a turn is counted after a // checkpoint write, so back-to-back checkpoints with no prompt between them share -// a count. Floored at 1 so we never record 0 (covers attach, a fast-path -// checkpoint before any turn, and exec-mode gaps where turns weren't counted). +// a count. Floored at 1 so we never record 0 (covers a fast-path checkpoint +// before any turn, and exec-mode gaps where turns weren't counted). Attach has +// its own count (see attachStepCount); it does not go through this path. func checkpointStepCount(s *SessionState) int { if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 { return w From d54a4e82aba08f388b39890aa6ca8aee13ea0fd4 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Wed, 3 Jun 2026 18:05:50 +0200 Subject: [PATCH 3/4] Fix attach step count to use real transcript turn count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachStepCount floored on len(prompts), but prompts is only ever [meta.FirstPrompt] (≤1), so every attached session reported 1 step regardless of length. Use meta.TurnCount (the user-prompt count already parsed from the transcript) instead, still floored at 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/attach.go | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 9f1a9ce5e..535cf40dc 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -150,13 +150,23 @@ func runAttachSurfaceReviewErrors(cmd *cobra.Command, sessionID string, agentNam return err } -// attachStepCount returns the displayed "steps" count for an attached session. -// Attach writes exactly one checkpoint and has at most meta.FirstPrompt, so this -// floors at 1 — it must never render as "0 steps". SaveStepCount stays 0 (no -// SaveStep ran), keeping the combined-attribution gate conservative for this -// fallback session. -func attachStepCount(prompts []string) int { - return max(len(prompts), 1) +// attachStepCount returns the displayed "steps" count for an attached session: +// the number of user prompts (turns) in the attached transcript, as counted by +// extractTranscriptMetadata. Floored at 1 so it never renders as "0 steps" for an +// empty/unparseable transcript. SaveStepCount stays 0 (no SaveStep ran), keeping +// the combined-attribution gate conservative for this fallback session. +func attachStepCount(turnCount int) int { + return max(turnCount, 1) +} + +// attachPrompts returns the prompts recorded on an attached checkpoint. Attach +// records only the first user prompt (used for the display title); the full +// per-turn list isn't reconstructed for post-hoc imports. +func attachPrompts(meta transcriptMetadata) []string { + if meta.FirstPrompt == "" { + return nil + } + return []string{meta.FirstPrompt} } func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, opts attachOptions) error { @@ -289,11 +299,6 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ return fmt.Errorf("failed to get git author: %w", err) } - var prompts []string - if meta.FirstPrompt != "" { - prompts = []string{meta.FirstPrompt} - } - tokenUsage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, "") _, redactSpan := perf.Start(ctx, "redact_transcript") @@ -308,8 +313,8 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ SessionID: sessionID, Strategy: strategy.StrategyNameManualCommit, Transcript: redactedTranscript, - Prompts: prompts, - CheckpointsCount: attachStepCount(prompts), + Prompts: attachPrompts(meta), + CheckpointsCount: attachStepCount(meta.TurnCount), AuthorName: author.Name, AuthorEmail: author.Email, Agent: ag.Type(), From cddaed87bff017b75bdc0fdf68f9adf7fdcd0bd6 Mon Sep 17 00:00:00 2001 From: dip Date: Wed, 3 Jun 2026 23:05:49 +0200 Subject: [PATCH 4/4] Clarify checkpoint step count docs Entire-Checkpoint: 29533fb28a76 --- cmd/entire/cli/checkpoint/checkpoint.go | 4 +++- docs/architecture/sessions-and-checkpoints.md | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 32e58cb3a..13a70d93d 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -425,7 +425,9 @@ type CommittedInfo struct { // CreatedAt is when the checkpoint was created CreatedAt time.Time - // CheckpointsCount is the total number of checkpoints across all sessions + // CheckpointsCount is the aggregate displayed "steps" count across sessions: + // the sum of per-session prompt-window counts. Despite the historical name, + // it is not a count of checkpoint records. CheckpointsCount int // FilesTouched are files modified during all sessions diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index a6ac74a1a..5d8c91806 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -231,6 +231,24 @@ Metadata only, sharded by checkpoint ID. Supports **multiple sessions per checkp } ``` +`checkpoints_count` in the root summary is the aggregate displayed "steps" count: the sum of per-session prompt-window counts. Despite the historical name, it is not a count of checkpoint records. + +**Session-level metadata.json (`CommittedMetadata`, abbreviated):** +```json +{ + "checkpoint_id": "abc123def456", + "session_id": "2025-12-01-8f76b0e8-b8f1-4a87-9186-848bdd83d62e", + "strategy": "manual-commit", + "created_at": "2025-12-01T12:34:56Z", + "branch": "main", + "checkpoints_count": 3, + "save_step_count": 3, + "files_touched": ["file1.txt", "file2.txt"] +} +``` + +In session metadata, `checkpoints_count` is the displayed prompt-window count for that session. `save_step_count` records SaveStep-created shadow-branch commits and is the conservative "real checkpoint work happened" signal; it is omitted when zero (for example, commit-only/fallback sessions). `save_step_count` is not aggregated into the root `CheckpointSummary`. + When condensing multiple concurrent sessions: - All sessions are stored in numbered subdirectories using 0-based indexing (`0/`, `1/`, `2/`, ...) - Each `session_id` is assigned a stable index; subsequent writes for the same session reuse the same numbered folder