diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go
index 855355f49..404831ced 100644
--- a/cmd/entire/cli/root.go
+++ b/cmd/entire/cli/root.go
@@ -91,6 +91,7 @@ func NewRootCmd() *cobra.Command {
 	cmd.AddCommand(newVersionCmd())
 	cmd.AddCommand(newExplainCmd())
 	cmd.AddCommand(newDoctorCmd())
+	cmd.AddCommand(newTraceCmd())
 	cmd.AddCommand(newTrailCmd())
 	cmd.AddCommand(newSendAnalyticsCmd())
 	cmd.AddCommand(newCurlBashPostInstallCmd())
diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go
index 7d2f68428..7cf02bee8 100644
--- a/cmd/entire/cli/strategy/manual_commit_hooks.go
+++ b/cmd/entire/cli/strategy/manual_commit_hooks.go
@@ -857,16 +857,20 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:
 	committedFileSet := filesChangedInCommit(ctx, worktreePath, commit, headTree, parentTree)
 	resolveTreesSpan.End()
 
+	loopCtx, processSessionsLoop := perf.StartLoop(ctx, "process_sessions")
 	for _, state := range sessions {
 		// Skip fully-condensed ended sessions — no work remains.
 		// These sessions only persist for LastCheckpointID (amend trailer reuse).
 		if state.FullyCondensed && state.Phase == session.PhaseEnded {
 			continue
 		}
-		s.postCommitProcessSession(ctx, repo, state, &transitionCtx, checkpointID,
+		iterCtx, iterSpan := processSessionsLoop.Iteration(loopCtx)
+		s.postCommitProcessSession(iterCtx, repo, state, &transitionCtx, checkpointID,
 			head, commit, newHead, worktreePath, headTree, parentTree, committedFileSet,
 			shadowBranchesToDelete, uncondensedActiveOnBranch)
+		iterSpan.End()
 	}
+	processSessionsLoop.End()
 
 	// Clean up shadow branches — only delete when ALL sessions on the branch are non-active
 	// or were condensed during this PostCommit.
diff --git a/cmd/entire/cli/trace.go b/cmd/entire/cli/trace.go
new file mode 100644
index 000000000..bf2ca7d91
--- /dev/null
+++ b/cmd/entire/cli/trace.go
@@ -0,0 +1,311 @@
+package cli
+
+import (
+	"bufio"
+	"cmp"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// traceStep represents a single timed step within a trace span.
+// Group steps (from nested spans) have SubSteps with 0-based iteration numbering.
+type traceStep struct {
+	Name       string
+	DurationMs int64
+	Error      bool
+	SubSteps   []traceStep
+}
+
+// traceEntry represents a parsed performance trace log entry.
+type traceEntry struct {
+	Op         string
+	DurationMs int64
+	Error      bool
+	Time       time.Time
+	Steps      []traceStep
+}
+
+// parseTraceEntry parses a JSON log line into a traceEntry.
+// Returns nil if the line is not valid JSON or is not a trace entry (msg != "perf").
+func parseTraceEntry(line string) *traceEntry {
+	// Cheap pre-filter: skip full JSON parse for lines that can't be perf entries.
+	// Most lines in the shared log file are non-perf, so this avoids the
+	// marshalling cost for the common reject path.
+	if !strings.Contains(line, `"msg":"perf"`) {
+		return nil
+	}
+
+	var raw map[string]json.RawMessage
+	if err := json.Unmarshal([]byte(line), &raw); err != nil {
+		return nil
+	}
+
+	// Verify msg == "perf" after full parse (the pre-filter could match substrings)
+	var msg string
+	if msgRaw, ok := raw["msg"]; !ok {
+		return nil
+	} else if err := json.Unmarshal(msgRaw, &msg); err != nil || msg != "perf" {
+		return nil
+	}
+
+	entry := &traceEntry{}
+
+	// Best-effort field extraction: missing or mistyped fields keep their
+	// zero values rather than discarding the entire entry.
+	if opRaw, ok := raw["op"]; ok {
+		_ = json.Unmarshal(opRaw, &entry.Op) //nolint:errcheck // best-effort
+	}
+	if dRaw, ok := raw["duration_ms"]; ok {
+		_ = json.Unmarshal(dRaw, &entry.DurationMs) //nolint:errcheck // best-effort
+	}
+	if errRaw, ok := raw["error"]; ok {
+		_ = json.Unmarshal(errRaw, &entry.Error) //nolint:errcheck // best-effort
+	}
+
+	// Extract time
+	if tRaw, ok := raw["time"]; ok {
+		var ts string
+		if err := json.Unmarshal(tRaw, &ts); err == nil {
+			if parsed, err := time.Parse(time.RFC3339, ts); err == nil {
+				entry.Time = parsed
+			}
+		}
+	}
+
+	// Extract steps by finding keys matching "steps.*_ms"
+	stepDurations := make(map[string]int64)
+	stepErrors := make(map[string]bool)
+
+	for key, val := range raw {
+		if strings.HasPrefix(key, "steps.") && strings.HasSuffix(key, "_ms") {
+			name := strings.TrimPrefix(key, "steps.")
+			name = strings.TrimSuffix(name, "_ms")
+
+			var ms int64
+			if err := json.Unmarshal(val, &ms); err == nil {
+				stepDurations[name] = ms
+			}
+		} else if strings.HasPrefix(key, "steps.") && strings.HasSuffix(key, "_err") {
+			name := strings.TrimPrefix(key, "steps.")
+			name = strings.TrimSuffix(name, "_err")
+
+			var errFlag bool
+			if err := json.Unmarshal(val, &errFlag); err == nil {
+				stepErrors[name] = errFlag
+			}
+		}
+	}
+
+	// Separate parent steps from sub-steps.
+	// A key like "foo.0" is a sub-step of "foo" if "foo" also exists as a parent
+	// and the last segment is a non-negative integer.
+	subStepDurations := make(map[string]map[int]int64) // parent -> index -> ms
+	subStepErrors := make(map[string]map[int]bool)     // parent -> index -> err
+	parentStepDurations := make(map[string]int64)
+	parentStepErrors := make(map[string]bool)
+
+	for name, ms := range stepDurations {
+		if parent, idx, ok := parseSubStepKey(name, stepDurations); ok {
+			if subStepDurations[parent] == nil {
+				subStepDurations[parent] = make(map[int]int64)
+			}
+			subStepDurations[parent][idx] = ms
+			if stepErrors[name] {
+				if subStepErrors[parent] == nil {
+					subStepErrors[parent] = make(map[int]bool)
+				}
+				subStepErrors[parent][idx] = true
+			}
+		} else {
+			parentStepDurations[name] = ms
+			parentStepErrors[name] = stepErrors[name]
+		}
+	}
+
+	// Build steps slice sorted alphabetically by name
+	steps := make([]traceStep, 0, len(parentStepDurations))
+	for name, ms := range parentStepDurations {
+		step := traceStep{
+			Name:       name,
+			DurationMs: ms,
+			Error:      parentStepErrors[name],
+		}
+
+		// Attach sub-steps if any, sorted by numeric index
+		if subs, ok := subStepDurations[name]; ok {
+			indices := make([]int, 0, len(subs))
+			for idx := range subs {
+				indices = append(indices, idx)
+			}
+			slices.Sort(indices)
+
+			subList := make([]traceStep, 0, len(subs))
+			for _, idx := range indices {
+				subList = append(subList, traceStep{
+					Name:       fmt.Sprintf("%s.%d", name, idx),
+					DurationMs: subs[idx],
+					Error:      subStepErrors[name][idx],
+				})
+			}
+			step.SubSteps = subList
+		}
+
+		steps = append(steps, step)
+	}
+	slices.SortFunc(steps, func(a, b traceStep) int {
+		return cmp.Compare(a.Name, b.Name)
+	})
+
+	entry.Steps = steps
+
+	return entry
+}
+
+// parseSubStepKey checks if a step name like "foo.0" is a sub-step of "foo".
+// Returns the parent name, index, and true if it is a sub-step.
+// A name is a sub-step if: the last segment after the final "." is a non-negative
+// integer AND the parent name exists in allSteps.
+func parseSubStepKey(name string, allSteps map[string]int64) (string, int, bool) {
+	lastDot := strings.LastIndex(name, ".")
+	if lastDot < 0 {
+		return "", 0, false
+	}
+	parent := name[:lastDot]
+	suffix := name[lastDot+1:]
+	idx, err := strconv.Atoi(suffix)
+	if err != nil || idx < 0 {
+		return "", 0, false
+	}
+	if _, exists := allSteps[parent]; !exists {
+		return "", 0, false
+	}
+	return parent, idx, true
+}
+
+// collectTraceEntries reads a JSONL log file and returns the last N trace entries,
+// ordered newest first. If hookFilter is non-empty, only entries with a matching
+// Op field are included.
+func collectTraceEntries(logFile string, last int, hookFilter string) ([]traceEntry, error) {
+	f, err := os.Open(logFile) //nolint:gosec // logFile is a CLI-resolved path, not user-supplied input
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("opening log file: %w", err)
+	}
+	defer f.Close()
+
+	var entries []traceEntry
+
+	scanner := bufio.NewScanner(f)
+	scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), 1024*1024) // allow up to 1MB lines in shared log file
+	for scanner.Scan() {
+		entry := parseTraceEntry(scanner.Text())
+		if entry == nil {
+			continue
+		}
+		if hookFilter != "" && entry.Op != hookFilter {
+			continue
+		}
+		entries = append(entries, *entry)
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, fmt.Errorf("reading log file: %w", err)
+	}
+
+	// Take the last N entries
+	if len(entries) > last {
+		entries = entries[len(entries)-last:]
+	}
+
+	// Reverse so newest entries are first
+	slices.Reverse(entries)
+
+	return entries, nil
+}
+
+// renderTraceEntries writes a formatted table of trace entries to w.
+// If entries is empty, it prints a help message about enabling traces.
+func renderTraceEntries(w io.Writer, entries []traceEntry) {
+	if len(entries) == 0 {
+		fmt.Fprintln(w, "No trace entries found.")
+		fmt.Fprintln(w, `Traces are logged at DEBUG level. Make sure ENTIRE_LOG_LEVEL=DEBUG is set`)
+		fmt.Fprintln(w, `in your shell profile, or set log_level to "DEBUG" in .entire/settings.json.`)
+		return
+	}
+
+	for i, entry := range entries {
+		if i > 0 {
+			fmt.Fprintln(w)
+		}
+
+		// Header line: op  duration  [timestamp]
+		header := fmt.Sprintf("%s  %dms", entry.Op, entry.DurationMs)
+		if !entry.Time.IsZero() {
+			header += "  " + entry.Time.Format(time.RFC3339)
+		}
+		fmt.Fprintln(w, header)
+		fmt.Fprintln(w)
+
+		if len(entry.Steps) == 0 {
+			continue
+		}
+
+		// Compute max name display width (at least len("STEP")).
+		// Sub-steps are indented 5 extra display columns relative to parent rows
+		// ("    " + "├─ " = 7 display cols vs "  " = 2 display cols).
+		const subExtraIndent = 5
+		nameWidth := len("STEP")
+		for _, s := range entry.Steps {
+			if len(s.Name) > nameWidth {
+				nameWidth = len(s.Name)
+			}
+			for _, sub := range s.SubSteps {
+				if needed := len(sub.Name) + subExtraIndent; needed > nameWidth {
+					nameWidth = needed
+				}
+			}
+		}
+
+		// Column header
+		fmt.Fprintf(w, "  %-*s  %8s\n", nameWidth, "STEP", "DURATION")
+
+		// Step rows
+		for _, s := range entry.Steps {
+			dur := fmt.Sprintf("%dms", s.DurationMs)
+			line := fmt.Sprintf("  %-*s  %8s", nameWidth, s.Name, dur)
+			if s.Error {
+				line += "  x"
+			}
+			fmt.Fprintln(w, line)
+
+			// Sub-step rows with ASCII tree connectors.
+			// Pad manually to avoid multi-byte UTF-8 box-drawing chars
+			// (├─, └─) breaking Go's byte-based %-*s alignment.
+			for i, sub := range s.SubSteps {
+				connector := "├─"
+				if i == len(s.SubSteps)-1 {
+					connector = "└─"
+				}
+				subDur := fmt.Sprintf("%dms", sub.DurationMs)
+				pad := nameWidth - subExtraIndent - len(sub.Name)
+				if pad < 0 {
+					pad = 0
+				}
+				subLine := fmt.Sprintf("    %s %s%s  %8s", connector, sub.Name, strings.Repeat(" ", pad), subDur)
+				if sub.Error {
+					subLine += "  x"
+				}
+				fmt.Fprintln(w, subLine)
+			}
+		}
+	}
+}
diff --git a/cmd/entire/cli/trace_cmd.go b/cmd/entire/cli/trace_cmd.go
new file mode 100644
index 000000000..428427c73
--- /dev/null
+++ b/cmd/entire/cli/trace_cmd.go
@@ -0,0 +1,57 @@
+package cli
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"github.com/entireio/cli/cmd/entire/cli/logging"
+	"github.com/entireio/cli/cmd/entire/cli/paths"
+	"github.com/spf13/cobra"
+)
+
+func newTraceCmd() *cobra.Command {
+	var last int
+	var hookFilter string
+
+	cmd := &cobra.Command{
+		Use:   "trace",
+		Short: "Show hook performance traces",
+		Long: `Show timing information for recent hook invocations.
+
+Traces are emitted at DEBUG log level. To enable them, either:
+  - Set ENTIRE_LOG_LEVEL=DEBUG in your shell profile
+  - Add "log_level": "DEBUG" to .entire/settings.json
+
+Examples:
+  entire trace                     Show the most recent hook trace
+  entire trace --last 5            Show the last 5 hook traces
+  entire trace --hook post-commit  Show only post-commit hook traces`,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			if last < 1 {
+				return fmt.Errorf("--last must be at least 1, got %d", last)
+			}
+
+			repoRoot, err := paths.WorktreeRoot(cmd.Context())
+			if err != nil {
+				cmd.SilenceUsage = true
+				fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Please run from within a git repository.")
+				return NewSilentError(fmt.Errorf("not a git repository: %w", err))
+			}
+
+			logFile := filepath.Join(repoRoot, logging.LogsDir, "entire.log")
+
+			entries, err := collectTraceEntries(logFile, last, hookFilter)
+			if err != nil {
+				return fmt.Errorf("collecting trace entries: %w", err)
+			}
+
+			renderTraceEntries(cmd.OutOrStdout(), entries)
+			return nil
+		},
+	}
+
+	cmd.Flags().IntVar(&last, "last", 1, "Show last N hook invocations")
+	cmd.Flags().StringVar(&hookFilter, "hook", "", "Filter by hook type (e.g. post-commit, prepare-commit-msg, pre-push)")
+
+	return cmd
+}
diff --git a/cmd/entire/cli/trace_test.go b/cmd/entire/cli/trace_test.go
new file mode 100644
index 000000000..c588b3875
--- /dev/null
+++ b/cmd/entire/cli/trace_test.go
@@ -0,0 +1,428 @@
+package cli
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+const testOpPostCommit = "post-commit"
+
+func TestParseTraceEntry(t *testing.T) {
+	t.Parallel()
+
+	t.Run("valid trace entry", func(t *testing.T) {
+		t.Parallel()
+
+		line := `{"time":"2026-01-15T10:30:00.000Z","level":"DEBUG","msg":"perf","component":"perf","op":"post-commit","duration_ms":150,"error":true,"steps.load_session_ms":50,"steps.save_checkpoint_ms":80,"steps.save_checkpoint_err":true}`
+
+		entry := parseTraceEntry(line)
+		if entry == nil {
+			t.Fatal("parseTraceEntry returned nil for valid trace entry")
+		}
+
+		if entry.Op != testOpPostCommit {
+			t.Errorf("Op = %q, want %q", entry.Op, testOpPostCommit)
+		}
+		if entry.DurationMs != 150 {
+			t.Errorf("DurationMs = %d, want %d", entry.DurationMs, 150)
+		}
+		if !entry.Error {
+			t.Error("Error = false, want true")
+		}
+
+		expectedTime, err := time.Parse(time.RFC3339, "2026-01-15T10:30:00.000Z")
+		if err != nil {
+			t.Fatalf("failed to parse expected time: %v", err)
+		}
+		if !entry.Time.Equal(expectedTime) {
+			t.Errorf("Time = %v, want %v", entry.Time, expectedTime)
+		}
+
+		if len(entry.Steps) != 2 {
+			t.Fatalf("len(Steps) = %d, want 2", len(entry.Steps))
+		}
+
+		// Steps are sorted alphabetically by name
+		if entry.Steps[0].Name != "load_session" {
+			t.Errorf("Steps[0].Name = %q, want %q", entry.Steps[0].Name, "load_session")
+		}
+		if entry.Steps[0].DurationMs != 50 {
+			t.Errorf("Steps[0].DurationMs = %d, want %d", entry.Steps[0].DurationMs, 50)
+		}
+		if entry.Steps[0].Error {
+			t.Error("Steps[0].Error = true, want false")
+		}
+
+		if entry.Steps[1].Name != "save_checkpoint" {
+			t.Errorf("Steps[1].Name = %q, want %q", entry.Steps[1].Name, "save_checkpoint")
+		}
+		if entry.Steps[1].DurationMs != 80 {
+			t.Errorf("Steps[1].DurationMs = %d, want %d", entry.Steps[1].DurationMs, 80)
+		}
+		if !entry.Steps[1].Error {
+			t.Error("Steps[1].Error = false, want true")
+		}
+	})
+
+	t.Run("non-perf entry returns nil", func(t *testing.T) {
+		t.Parallel()
+
+		line := `{"time":"2026-01-15T10:30:00.000Z","level":"INFO","msg":"hook invoked","component":"lifecycle","hook":"post-commit"}`
+
+		entry := parseTraceEntry(line)
+		if entry != nil {
+			t.Errorf("parseTraceEntry returned %+v for non-perf entry, want nil", entry)
+		}
+	})
+
+	t.Run("invalid JSON returns nil", func(t *testing.T) {
+		t.Parallel()
+
+		entry := parseTraceEntry("this is not json at all{{{")
+		if entry != nil {
+			t.Errorf("parseTraceEntry returned %+v for invalid JSON, want nil", entry)
+		}
+	})
+}
+
+func TestCollectTraceEntries(t *testing.T) {
+	t.Parallel()
+
+	// Fixture: 4 lines — 2 prepare-commit-msg, 1 non-perf, 1 post-commit
+	fixtureLines := []string{
+		`{"time":"2026-01-15T10:00:00Z","level":"DEBUG","msg":"perf","op":"prepare-commit-msg","duration_ms":100}`,
+		`{"time":"2026-01-15T10:01:00Z","level":"DEBUG","msg":"perf","op":"prepare-commit-msg","duration_ms":120}`,
+		`{"time":"2026-01-15T10:02:00Z","level":"INFO","msg":"hook invoked","component":"lifecycle","hook":"post-commit"}`,
+		`{"time":"2026-01-15T10:03:00Z","level":"DEBUG","msg":"perf","op":"post-commit","duration_ms":200}`,
+	}
+	fixtureContent := strings.Join(fixtureLines, "\n") + "\n"
+
+	writeFixture := func(t *testing.T) string {
+		t.Helper()
+		dir := t.TempDir()
+		p := filepath.Join(dir, "trace.jsonl")
+		if err := os.WriteFile(p, []byte(fixtureContent), 0o644); err != nil {
+			t.Fatalf("failed to write fixture: %v", err)
+		}
+		return p
+	}
+
+	t.Run("last 2 entries", func(t *testing.T) {
+		t.Parallel()
+		logFile := writeFixture(t)
+
+		entries, err := collectTraceEntries(logFile, 2, "")
+		if err != nil {
+			t.Fatalf("collectTraceEntries returned error: %v", err)
+		}
+
+		if len(entries) != 2 {
+			t.Fatalf("got %d entries, want 2", len(entries))
+		}
+
+		// Newest first: post-commit (line 4), then prepare-commit-msg (line 2)
+		if entries[0].Op != testOpPostCommit {
+			t.Errorf("entries[0].Op = %q, want %q", entries[0].Op, testOpPostCommit)
+		}
+		if entries[0].DurationMs != 200 {
+			t.Errorf("entries[0].DurationMs = %d, want %d", entries[0].DurationMs, 200)
+		}
+		if entries[1].Op != "prepare-commit-msg" {
+			t.Errorf("entries[1].Op = %q, want %q", entries[1].Op, "prepare-commit-msg")
+		}
+		if entries[1].DurationMs != 120 {
+			t.Errorf("entries[1].DurationMs = %d, want %d", entries[1].DurationMs, 120)
+		}
+	})
+
+	t.Run("filter by hook type", func(t *testing.T) {
+		t.Parallel()
+		logFile := writeFixture(t)
+
+		entries, err := collectTraceEntries(logFile, 10, testOpPostCommit)
+		if err != nil {
+			t.Fatalf("collectTraceEntries returned error: %v", err)
+		}
+
+		if len(entries) != 1 {
+			t.Fatalf("got %d entries, want 1", len(entries))
+		}
+		if entries[0].Op != testOpPostCommit {
+			t.Errorf("entries[0].Op = %q, want %q", entries[0].Op, testOpPostCommit)
+		}
+	})
+
+	t.Run("file not found returns empty", func(t *testing.T) {
+		t.Parallel()
+
+		entries, err := collectTraceEntries("/nonexistent/path/trace.jsonl", 10, "")
+		if err != nil {
+			t.Fatalf("expected nil error for missing file, got %v", err)
+		}
+		if len(entries) != 0 {
+			t.Errorf("expected empty entries, got %d", len(entries))
+		}
+	})
+}
+
+func TestRenderTraceEntries(t *testing.T) {
+	t.Parallel()
+
+	entries := []traceEntry{
+		{
+			Op:         "post-commit",
+			DurationMs: 250,
+			Time:       time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
+			Steps: []traceStep{
+				{Name: "load_session", DurationMs: 50, Error: false},
+				{Name: "save_checkpoint", DurationMs: 80, Error: true},
+				{Name: "condense", DurationMs: 120, Error: false},
+			},
+		},
+	}
+
+	var buf bytes.Buffer
+	renderTraceEntries(&buf, entries)
+	out := buf.String()
+
+	// Verify header contains op name, total duration, and timestamp
+	if !strings.Contains(out, "post-commit") {
+		t.Errorf("output missing op name 'post-commit':\n%s", out)
+	}
+	if !strings.Contains(out, "250ms") {
+		t.Errorf("output missing total duration '250ms':\n%s", out)
+	}
+	if !strings.Contains(out, "2026-01-15T10:30:00Z") {
+		t.Errorf("output missing RFC3339 timestamp:\n%s", out)
+	}
+
+	// Verify step names and durations appear
+	if !strings.Contains(out, "load_session") {
+		t.Errorf("output missing step name 'load_session':\n%s", out)
+	}
+	if !strings.Contains(out, "50ms") {
+		t.Errorf("output missing step duration '50ms':\n%s", out)
+	}
+	if !strings.Contains(out, "save_checkpoint") {
+		t.Errorf("output missing step name 'save_checkpoint':\n%s", out)
+	}
+	if !strings.Contains(out, "80ms") {
+		t.Errorf("output missing step duration '80ms':\n%s", out)
+	}
+	if !strings.Contains(out, "condense") {
+		t.Errorf("output missing step name 'condense':\n%s", out)
+	}
+	if !strings.Contains(out, "120ms") {
+		t.Errorf("output missing step duration '120ms':\n%s", out)
+	}
+
+	// Verify column header
+	if !strings.Contains(out, "STEP") {
+		t.Errorf("output missing column header 'STEP':\n%s", out)
+	}
+	if !strings.Contains(out, "DURATION") {
+		t.Errorf("output missing column header 'DURATION':\n%s", out)
+	}
+
+	// Verify error marker on the errored step line
+	lines := strings.Split(out, "\n")
+	foundErrorMarker := false
+	for _, line := range lines {
+		if strings.Contains(line, "save_checkpoint") && strings.Contains(line, "x") {
+			foundErrorMarker = true
+			break
+		}
+	}
+	if !foundErrorMarker {
+		t.Errorf("output missing error marker 'x' on save_checkpoint line:\n%s", out)
+	}
+}
+
+func TestParseTraceEntry_WithSubSteps(t *testing.T) {
+	t.Parallel()
+
+	line := `{"time":"2026-01-15T10:30:00Z","level":"DEBUG","msg":"perf","op":"post-commit","duration_ms":350,"steps.process_sessions_ms":300,"steps.process_sessions.0_ms":100,"steps.process_sessions.1_ms":200,"steps.process_sessions.1_err":true,"steps.cleanup_ms":50}`
+
+	entry := parseTraceEntry(line)
+	if entry == nil {
+		t.Fatal("parseTraceEntry returned nil")
+	}
+
+	if len(entry.Steps) != 2 {
+		t.Fatalf("len(Steps) = %d, want 2", len(entry.Steps))
+	}
+
+	// Steps sorted alphabetically: cleanup, process_sessions
+	if entry.Steps[0].Name != "cleanup" {
+		t.Errorf("Steps[0].Name = %q, want %q", entry.Steps[0].Name, "cleanup")
+	}
+	if len(entry.Steps[0].SubSteps) != 0 {
+		t.Errorf("Steps[0] should have no sub-steps, got %d", len(entry.Steps[0].SubSteps))
+	}
+
+	ps := entry.Steps[1]
+	if ps.Name != "process_sessions" {
+		t.Errorf("Steps[1].Name = %q, want %q", ps.Name, "process_sessions")
+	}
+	if ps.DurationMs != 300 {
+		t.Errorf("Steps[1].DurationMs = %d, want %d", ps.DurationMs, 300)
+	}
+	if len(ps.SubSteps) != 2 {
+		t.Fatalf("Steps[1] should have 2 sub-steps, got %d", len(ps.SubSteps))
+	}
+	if ps.SubSteps[0].Name != "process_sessions.0" {
+		t.Errorf("SubSteps[0].Name = %q, want %q", ps.SubSteps[0].Name, "process_sessions.0")
+	}
+	if ps.SubSteps[0].DurationMs != 100 {
+		t.Errorf("SubSteps[0].DurationMs = %d, want %d", ps.SubSteps[0].DurationMs, 100)
+	}
+	if ps.SubSteps[0].Error {
+		t.Error("SubSteps[0].Error = true, want false")
+	}
+	if ps.SubSteps[1].Name != "process_sessions.1" {
+		t.Errorf("SubSteps[1].Name = %q, want %q", ps.SubSteps[1].Name, "process_sessions.1")
+	}
+	if ps.SubSteps[1].DurationMs != 200 {
+		t.Errorf("SubSteps[1].DurationMs = %d, want %d", ps.SubSteps[1].DurationMs, 200)
+	}
+	if !ps.SubSteps[1].Error {
+		t.Error("SubSteps[1].Error = false, want true")
+	}
+}
+
+func TestParseTraceEntry_SubStepNumericOrdering(t *testing.T) {
+	t.Parallel()
+
+	// Build a trace entry with 12 sub-steps to verify numeric (not lexicographic) ordering.
+	// Lexicographic sort would place .10 and .11 before .2.
+	line := `{"time":"2026-01-15T10:30:00Z","level":"DEBUG","msg":"perf","op":"post-commit","duration_ms":1000,"steps.loop_ms":900,"steps.loop.0_ms":10,"steps.loop.1_ms":20,"steps.loop.2_ms":30,"steps.loop.3_ms":40,"steps.loop.4_ms":50,"steps.loop.5_ms":60,"steps.loop.6_ms":70,"steps.loop.7_ms":80,"steps.loop.8_ms":90,"steps.loop.9_ms":100,"steps.loop.10_ms":110,"steps.loop.11_ms":120}`
+
+	entry := parseTraceEntry(line)
+	if entry == nil {
+		t.Fatal("parseTraceEntry returned nil")
+	}
+
+	if len(entry.Steps) != 1 {
+		t.Fatalf("len(Steps) = %d, want 1", len(entry.Steps))
+	}
+
+	subs := entry.Steps[0].SubSteps
+	if len(subs) != 12 {
+		t.Fatalf("len(SubSteps) = %d, want 12", len(subs))
+	}
+
+	for i, sub := range subs {
+		want := fmt.Sprintf("loop.%d", i)
+		if sub.Name != want {
+			t.Errorf("SubSteps[%d].Name = %q, want %q", i, sub.Name, want)
+		}
+		wantMs := int64((i + 1) * 10)
+		if sub.DurationMs != wantMs {
+			t.Errorf("SubSteps[%d].DurationMs = %d, want %d", i, sub.DurationMs, wantMs)
+		}
+	}
+}
+
+func TestRenderTraceEntries_WithSubSteps(t *testing.T) {
+	t.Parallel()
+
+	entries := []traceEntry{
+		{
+			Op:         "post-commit",
+			DurationMs: 350,
+			Steps: []traceStep{
+				{Name: "process_sessions", DurationMs: 300, SubSteps: []traceStep{
+					{Name: "process_sessions.0", DurationMs: 100},
+					{Name: "process_sessions.1", DurationMs: 200, Error: true},
+				}},
+				{Name: "cleanup", DurationMs: 50},
+			},
+		},
+	}
+
+	var buf bytes.Buffer
+	renderTraceEntries(&buf, entries)
+	out := buf.String()
+
+	// Parent step appears
+	if !strings.Contains(out, "process_sessions") {
+		t.Errorf("output missing group step 'process_sessions':\n%s", out)
+	}
+	if !strings.Contains(out, "300ms") {
+		t.Errorf("output missing group duration '300ms':\n%s", out)
+	}
+
+	// Sub-steps with tree connectors
+	if !strings.Contains(out, "├─ process_sessions.0") {
+		t.Errorf("output missing sub-step with tree connector '├─ process_sessions.0':\n%s", out)
+	}
+	if !strings.Contains(out, "└─ process_sessions.1") {
+		t.Errorf("output missing sub-step with tree connector '└─ process_sessions.1':\n%s", out)
+	}
+
+	// Error marker on sub-step
+	lines := strings.Split(out, "\n")
+	foundSubError := false
+	for _, line := range lines {
+		if strings.Contains(line, "process_sessions.1") && strings.Contains(line, "x") {
+			foundSubError = true
+			break
+		}
+	}
+	if !foundSubError {
+		t.Errorf("output missing error marker on process_sessions.1:\n%s", out)
+	}
+
+	// Regular step still appears
+	if !strings.Contains(out, "cleanup") {
+		t.Errorf("output missing regular step 'cleanup':\n%s", out)
+	}
+}
+
+func TestTraceCmd_InvalidLastFlag(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		args []string
+		want string
+	}{
+		{"zero", []string{"--last", "0"}, "--last must be at least 1, got 0"},
+		{"negative", []string{"--last", "-1"}, "--last must be at least 1, got -1"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			cmd := newTraceCmd()
+			cmd.SetArgs(tt.args)
+			cmd.SilenceUsage = true
+
+			err := cmd.Execute()
+			if err == nil {
+				t.Fatal("expected error, got nil")
+			}
+			if err.Error() != tt.want {
+				t.Errorf("error = %q, want %q", err.Error(), tt.want)
+			}
+		})
+	}
+}
+
+func TestRenderTraceEntries_Empty(t *testing.T) {
+	t.Parallel()
+
+	var buf bytes.Buffer
+	renderTraceEntries(&buf, nil)
+	out := buf.String()
+
+	if !strings.Contains(out, "No trace entries found.") {
+		t.Errorf("expected 'No trace entries found.' message, got:\n%s", out)
+	}
+}
diff --git a/perf/span.go b/perf/span.go
index 6d547596e..ee887979e 100644
--- a/perf/span.go
+++ b/perf/span.go
@@ -67,25 +67,50 @@ func (s *Span) End() {
 	// Build log attributes: op, duration_ms, error flag, then child step durations.
 	// Component is set via context so it appears exactly once in the log line.
 	logCtx := logging.WithComponent(s.ctx, "perf")
-	attrs := make([]any, 0, 3+2*len(s.children)+len(s.attrs))
+	grandchildren := 0
+	for _, c := range s.children {
+		grandchildren += len(c.children)
+	}
+	attrs := make([]any, 0, 3+2*len(s.children)+2*grandchildren+len(s.attrs))
 	attrs = append(attrs, slog.String("op", s.name))
 	attrs = append(attrs, slog.Int64("duration_ms", s.duration.Milliseconds()))
 	if s.err != nil {
 		attrs = append(attrs, slog.Bool("error", true))
 	}
 
-	// Add child step durations (and error flags) as flat keys
+	// Add child step durations (and error flags) as flat keys.
+	// Disambiguate duplicate child names (from loops) with ~1, ~2, etc. suffixes
+	// to prevent later values from overwriting earlier ones in JSON output.
+	//
+	// Group spans (children that have their own children) also emit grandchildren
+	// with 0-based indexing: steps.<name>.0_ms, steps.<name>.1_ms, etc. The ~N
+	// suffix avoids collisions with this .0, .1, ... iteration indexing.
+	seen := make(map[string]int, len(s.children))
 	for _, child := range s.children {
 		// Auto-end children that were not explicitly ended
 		if !child.ended {
 			child.End()
 		}
-		key := fmt.Sprintf("steps.%s_ms", child.name)
+		stepKey := childStepKey(child.name, seen)
+		key := fmt.Sprintf("steps.%s_ms", stepKey)
 		attrs = append(attrs, slog.Int64(key, child.duration.Milliseconds()))
 		if child.err != nil {
-			errKey := fmt.Sprintf("steps.%s_err", child.name)
+			errKey := fmt.Sprintf("steps.%s_err", stepKey)
 			attrs = append(attrs, slog.Bool(errKey, true))
 		}
+
+		// Emit grandchildren (group iterations) with 0-based indexing
+		for i, gc := range child.children {
+			if !gc.ended {
+				gc.End()
+			}
+			gcKey := fmt.Sprintf("steps.%s.%d_ms", stepKey, i)
+			attrs = append(attrs, slog.Int64(gcKey, gc.duration.Milliseconds()))
+			if gc.err != nil {
+				gcErrKey := fmt.Sprintf("steps.%s.%d_err", stepKey, i)
+				attrs = append(attrs, slog.Bool(gcErrKey, true))
+			}
+		}
 	}
 
 	// Add any extra attributes from Start()
@@ -95,3 +120,58 @@ func (s *Span) End() {
 
 	logging.Debug(logCtx, "perf", attrs...)
 }
+
+// childStepKey returns a unique key for a child span name.
+// First occurrence keeps the original name; subsequent get ~1, ~2, etc.
+// Uses "~" separator to avoid collision with grandchild "." indexing
+// (e.g. steps.foo.0_ms for loop iterations).
+// The seen map is updated in place.
+func childStepKey(name string, seen map[string]int) string {
+	n := seen[name]
+	seen[name] = n + 1
+	if n == 0 {
+		return name
+	}
+	return fmt.Sprintf("%s~%d", name, n)
+}
+
+// LoopSpan wraps a Span that groups loop iterations. Each call to Iteration
+// creates a child span representing one pass through the loop.
+//
+// Usage:
+//
+//	ctx, loop := perf.StartLoop(ctx, "process_sessions")
+//	for _, item := range items {
+//	    iterCtx, iterSpan := loop.Iteration(ctx)
+//	    doWork(iterCtx, item)
+//	    iterSpan.End()
+//	}
+//	loop.End()
+type LoopSpan struct {
+	span *Span
+}
+
+// StartLoop begins a new loop span. The returned context contains the loop span
+// and should be passed to Iteration. Call loop.End() after the loop completes.
+func StartLoop(ctx context.Context, name string, attrs ...slog.Attr) (context.Context, *LoopSpan) {
+	ctx, s := Start(ctx, name, attrs...)
+	return ctx, &LoopSpan{span: s}
+}
+
+// Iteration creates a child span for a single loop iteration. The caller must
+// call End() on the returned span when the iteration completes.
+func (l *LoopSpan) Iteration(ctx context.Context) (context.Context, *Span) {
+	return Start(ctx, l.span.name)
+}
+
+// End completes the loop span, auto-ending any unended iteration children first
+// so their durations are captured at loop-end time rather than deferring to the
+// root span's End() (which may run much later).
+func (l *LoopSpan) End() {
+	for _, child := range l.span.children {
+		if !child.ended {
+			child.End()
+		}
+	}
+	l.span.End()
+}
diff --git a/perf/span_test.go b/perf/span_test.go
index 41374f1b6..b728f019f 100644
--- a/perf/span_test.go
+++ b/perf/span_test.go
@@ -205,6 +205,163 @@ func TestRecordError_ChildErrorFlagInOutput(t *testing.T) {
 	}
 }
 
+func TestChildStepKey_Deduplication(t *testing.T) {
+	t.Parallel()
+
+	const (
+		stepCheck  = "check_content"
+		stepSave   = "save_state"
+		stepUnique = "unique_step"
+	)
+
+	seen := make(map[string]int)
+
+	// First occurrence keeps original name
+	if got := childStepKey(stepCheck, seen); got != stepCheck {
+		t.Errorf("first check_content = %q, want %q", got, stepCheck)
+	}
+	if got := childStepKey(stepSave, seen); got != stepSave {
+		t.Errorf("first save_state = %q, want %q", got, stepSave)
+	}
+
+	// Second occurrence gets ~1 suffix
+	if got := childStepKey(stepCheck, seen); got != "check_content~1" {
+		t.Errorf("second check_content = %q, want %q", got, "check_content~1")
+	}
+	if got := childStepKey(stepSave, seen); got != "save_state~1" {
+		t.Errorf("second save_state = %q, want %q", got, "save_state~1")
+	}
+
+	// Third occurrence gets ~2
+	if got := childStepKey(stepCheck, seen); got != "check_content~2" {
+		t.Errorf("third check_content = %q, want %q", got, "check_content~2")
+	}
+
+	// Unique names are unaffected
+	if got := childStepKey(stepUnique, seen); got != stepUnique {
+		t.Errorf("unique_step = %q, want %q", got, stepUnique)
+	}
+}
+
+func TestEnd_DuplicateChildNames_AllPreserved(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	ctx, parent := Start(ctx, "post-commit")
+
+	// Simulate a loop creating children with the same names (e.g., per-session spans)
+	_, c1 := Start(ctx, "check_content")
+	c1.duration = 3000 * time.Millisecond
+	c1.ended = true
+
+	_, c2 := Start(ctx, "save_state")
+	c2.duration = 5 * time.Millisecond
+	c2.ended = true
+
+	// Second iteration — same names
+	_, c3 := Start(ctx, "check_content")
+	c3.duration = 0
+	c3.ended = true
+	c3.err = errors.New("test error")
+
+	_, c4 := Start(ctx, "save_state")
+	c4.duration = 2 * time.Millisecond
+	c4.ended = true
+
+	parent.End()
+
+	if len(parent.children) != 4 {
+		t.Fatalf("expected 4 children, got %d", len(parent.children))
+	}
+
+	// Verify the children have correct names for deduplication.
+	// End() uses childStepKey which gives: check_content, save_state, check_content~1, save_state~1
+	// We verify the structure is intact so End() can produce unique keys.
+	names := make([]string, len(parent.children))
+	for i, c := range parent.children {
+		names[i] = c.name
+	}
+	// All 4 children exist with their original names (dedup happens at serialization)
+	if names[0] != "check_content" || names[1] != "save_state" ||
+		names[2] != "check_content" || names[3] != "save_state" {
+		t.Errorf("children names = %v, expected [check_content save_state check_content save_state]", names)
+	}
+}
+
+func TestStartLoop_CreatesGroupSpan(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	ctx, root := Start(ctx, "post-commit")
+
+	loopCtx, loop := StartLoop(ctx, "process_sessions")
+	_, iter0 := loop.Iteration(loopCtx)
+	iter0.duration = 100 * time.Millisecond
+	iter0.ended = true
+	_, iter1 := loop.Iteration(loopCtx)
+	iter1.duration = 200 * time.Millisecond
+	iter1.ended = true
+	loop.span.duration = 300 * time.Millisecond
+	loop.span.ended = true
+
+	root.End()
+
+	// Verify loop span has 2 children (the iterations)
+	if len(loop.span.children) != 2 {
+		t.Fatalf("loop should have 2 children, got %d", len(loop.span.children))
+	}
+	if loop.span.children[0] != iter0 {
+		t.Error("loop.children[0] should be iter0")
+	}
+	if loop.span.children[1] != iter1 {
+		t.Error("loop.children[1] should be iter1")
+	}
+}
+
+func TestStartLoop_IterationsAutoEnded(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	ctx, root := Start(ctx, "hook")
+	loopCtx, loop := StartLoop(ctx, "loop")
+	_, iter0 := loop.Iteration(loopCtx)
+	iter0.duration = 50 * time.Millisecond
+	// iter0.ended intentionally NOT set — should be auto-ended when loop.End() runs
+	loop.End()
+
+	if !iter0.ended {
+		t.Error("iteration should be auto-ended when loop.End() runs")
+	}
+
+	root.End()
+}
+
+func TestStartLoop_IterationWithErrors(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	ctx, root := Start(ctx, "hook")
+	loopCtx, loop := StartLoop(ctx, "process_sessions")
+	_, iter0 := loop.Iteration(loopCtx)
+	iter0.duration = 100 * time.Millisecond
+	iter0.ended = true
+	_, iter1 := loop.Iteration(loopCtx)
+	iter1.duration = 200 * time.Millisecond
+	iter1.RecordError(errors.New("session failed"))
+	iter1.ended = true
+	loop.span.duration = 300 * time.Millisecond
+	loop.span.ended = true
+
+	root.End()
+
+	if iter0.err != nil {
+		t.Error("iter0 should have no error")
+	}
+	if iter1.err == nil {
+		t.Error("iter1 should have error recorded")
+	}
+}
+
 func TestEnd_NoErrorFlagByDefault(t *testing.T) {
 	t.Parallel()
 	ctx := context.Background()
