diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1b8dc..8828178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to ccx are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## [Unreleased] + +Findings 3-5 from the 0.11.0 field report (#21-#23) — the trace answers "what did it run" and stops crying wolf in containers. + +### Added +- **Mutation evidence carries the command it ran.** `ToolCallEvidence` gains a bounded one-line `summary` for command-carrying tools (Bash and the provider dialects normalized onto the `command` input). Paths answered "did this step touch the workspace" while hiding *how* — auditing a Bash mutation meant opening the raw JSONL. (#21) +- **`ccx trace --width N` controls outline headline truncation** (0 = untruncated; default stays 160), applied to text and JSON outlines alike. Previously a constant, and the only escape was `--full`'s entire bundle — which bit JSON skill/script consumers hardest. (#23) + +### Fixed +- **Git-root fallback is provenance, not a warning.** `session_git_root_missing` fired whenever the session's recorded cwd didn't resolve locally, even when the process-cwd fallback then found the right repo — the common case in containers, where every trace carried the warning despite correct correlation. A successful fallback now records `git.resolved_from` (`"session_cwd"` | `"process_cwd"`); the warning fires only when nothing resolves. (#22) + ## [0.12.0] - 2026-07-24 Both changes answer the second field report — the first one produced by ccx tracing itself: a user audited a live session's trace and caught its two headline numbers lying. Findings 3-5 from the same report are tracked in issues #21-#23. diff --git a/internal/cmd/scope_test.go b/internal/cmd/scope_test.go index 99aecdf..d57923a 100644 --- a/internal/cmd/scope_test.go +++ b/internal/cmd/scope_test.go @@ -179,7 +179,7 @@ func TestLatestTraceSessionIsNonInteractiveLatest(t *testing.T) { } } -func TestFindGitRootForSessionFallsBackWithWarning(t *testing.T) { +func TestFindGitRootForSessionFallbackIsProvenanceNotWarning(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not found") } @@ -196,10 +196,57 @@ func TestFindGitRootForSessionFallsBackWithWarning(t *testing.T) { } stale := filepath.Join(dir, "missing") - root, warnings := findGitRootForSession(&parser.Session{CWD: stale}) + root, resolvedFrom, warnings := findGitRootForSession(&parser.Session{CWD: stale}) if root != dir { t.Fatalf("root = %q, want %q", root, dir) } + if resolvedFrom != "process_cwd" { + t.Fatalf("resolvedFrom = %q, want process_cwd", resolvedFrom) + } + if len(warnings) != 0 { + t.Fatalf("successful fallback must not warn, got %+v", warnings) + } +} + +func TestFindGitRootForSessionSessionCWDWins(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found") + } + dir := t.TempDir() + runGit(t, dir, "init") + + root, resolvedFrom, warnings := findGitRootForSession(&parser.Session{CWD: dir}) + if root != dir { + t.Fatalf("root = %q, want %q", root, dir) + } + if resolvedFrom != "session_cwd" { + t.Fatalf("resolvedFrom = %q, want session_cwd", resolvedFrom) + } + if len(warnings) != 0 { + t.Fatalf("direct resolution must not warn, got %+v", warnings) + } +} + +func TestFindGitRootForSessionNothingResolvesWarns(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found") + } + nonRepo := t.TempDir() + + oldwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + if err := os.Chdir(nonRepo); err != nil { + t.Fatal(err) + } + + stale := filepath.Join(nonRepo, "missing") + root, resolvedFrom, warnings := findGitRootForSession(&parser.Session{CWD: stale}) + if root != "" || resolvedFrom != "" { + t.Fatalf("root = %q resolvedFrom = %q, want empty", root, resolvedFrom) + } if len(warnings) != 1 || warnings[0].Kind != "session_git_root_missing" { t.Fatalf("warnings = %+v, want session_git_root_missing", warnings) } diff --git a/internal/cmd/trace.go b/internal/cmd/trace.go index 3877716..da37728 100644 --- a/internal/cmd/trace.go +++ b/internal/cmd/trace.go @@ -47,6 +47,7 @@ var ( traceJSON bool traceTurn int traceFull bool + traceWidth int ) func init() { @@ -60,6 +61,7 @@ func addTraceFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&traceJSON, "json", false, "outline as JSON") cmd.Flags().IntVar(&traceTurn, "turn", 0, "full evidence for one turn (JSON)") cmd.Flags().BoolVar(&traceFull, "full", false, "complete trace bundle (JSON)") + cmd.Flags().IntVar(&traceWidth, "width", trace.DefaultHeadlineWidth, "outline headline width in runes (0 = untruncated)") } func runTrace(cmd *cobra.Command, args []string) error { @@ -80,9 +82,10 @@ func runTrace(cmd *cobra.Command, args []string) error { result := trace.Analyze(fullSession) - repoDir, gitRootWarnings := findGitRootForSession(fullSession) + repoDir, resolvedFrom, gitRootWarnings := findGitRootForSession(fullSession) result.Warnings = append(result.Warnings, gitRootWarnings...) if repoDir != "" { + result.Git.ResolvedFrom = resolvedFrom if err := trace.CorrelateGit(result, repoDir); err != nil { result.Warnings = append(result.Warnings, trace.TraceWarning{ Kind: "git_correlation_failed", @@ -137,9 +140,9 @@ func renderTrace(result *trace.TraceResult) ([]byte, error) { case traceFull: return json.MarshalIndent(result, "", " ") case traceJSON: - return json.MarshalIndent(trace.BuildOutline(result), "", " ") + return json.MarshalIndent(trace.BuildOutline(result, traceWidth), "", " ") default: - return []byte(trace.RenderOutlineText(trace.BuildOutline(result))), nil + return []byte(trace.RenderOutlineText(trace.BuildOutline(result, traceWidth))), nil } } @@ -197,24 +200,32 @@ func resolveTraceSession(backend provider.Backend, args []string) (*parser.Sessi return session, nil } -func findGitRootForSession(session *parser.Session) (string, []trace.TraceWarning) { - var warnings []trace.TraceWarning - if session != nil && session.CWD != "" { - if root := findGitRootFrom(session.CWD); root != "" { - return root, nil +// findGitRootForSession resolves the repo for git correlation: the +// session's recorded cwd first, then the process cwd. A successful +// fallback is normal in containerized/remote setups (the session was +// recorded under a host path), so it is reported as provenance on the +// git block, not as a warning. Warnings fire only when nothing resolves. +func findGitRootForSession(session *parser.Session) (string, string, []trace.TraceWarning) { + sessionCWD := "" + if session != nil { + sessionCWD = session.CWD + } + if root := findGitRootFrom(sessionCWD); root != "" { + return root, "session_cwd", nil + } + if cwd, err := os.Getwd(); err == nil { + if root := findGitRootFrom(cwd); root != "" { + return root, "process_cwd", nil } + } + var warnings []trace.TraceWarning + if sessionCWD != "" { warnings = append(warnings, trace.TraceWarning{ Kind: "session_git_root_missing", - Message: fmt.Sprintf("session cwd %q is missing or not inside a git repository; falling back to current working directory", session.CWD), + Message: fmt.Sprintf("session cwd %q is missing or not inside a git repository", sessionCWD), }) } - cwd, err := os.Getwd() - if err == nil { - if root := findGitRootFrom(cwd); root != "" { - return root, warnings - } - } - return "", warnings + return "", "", warnings } func findGitRootFrom(dir string) string { diff --git a/internal/trace/active_test.go b/internal/trace/active_test.go index 7afccf7..310e6ec 100644 --- a/internal/trace/active_test.go +++ b/internal/trace/active_test.go @@ -47,7 +47,7 @@ func TestActiveTimeCapsGaps(t *testing.T) { t.Fatal("wall span must remain reported alongside active time") } - outline := BuildOutline(result) + outline := BuildOutline(result, DefaultHeadlineWidth) if outline.Turns[0].ActiveSecs != wantActive { t.Fatalf("outline turn active = %vs, want %vs", outline.Turns[0].ActiveSecs, wantActive) } @@ -75,7 +75,7 @@ func TestRenderOutlineHeaderStatesActiveAndTimezone(t *testing.T) { }, } - text := RenderOutlineText(BuildOutline(Analyze(session))) + text := RenderOutlineText(BuildOutline(Analyze(session), DefaultHeadlineWidth)) if !strings.Contains(text, "| active 3m |") { t.Fatalf("header must state active time, got:\n%s", text) } diff --git a/internal/trace/analysis.go b/internal/trace/analysis.go index 1e75220..73a6977 100644 --- a/internal/trace/analysis.go +++ b/internal/trace/analysis.go @@ -315,6 +315,7 @@ func buildTurn(index int, anchor *parser.Message, messages []*parser.Message, si ToolID: cb.ToolID, Name: cb.ToolName, Timestamp: msg.Timestamp, + Summary: commandSummary(cb.ToolInput), Paths: paths, MutationCapable: mutatingTools[cb.ToolName], MutatesWorkspace: mutatesWorkspace, @@ -470,6 +471,7 @@ func collectSidechainEvidence(messages []*parser.Message) map[string]Sidechain { ToolID: block.ToolID, Name: block.ToolName, Timestamp: msg.Timestamp, + Summary: commandSummary(block.ToolInput), Paths: paths, MutationCapable: mutatingTools[block.ToolName], MutatesWorkspace: mutatesWorkspace, @@ -622,6 +624,32 @@ func cleanEvidencePath(path string) string { return path } +// evidenceCommandRunes bounds the one-line command excerpt carried on +// tool-call evidence. Short by design: the summary answers "what did +// this step run", the raw JSONL (via message_id) holds the rest. +const evidenceCommandRunes = 200 + +// commandSummary extracts what a command-carrying tool call ran, as +// one bounded line. Providers normalize their shell dialects onto a +// "command" string input (Bash, exec_command, run_terminal_command), +// so that key is the contract. +func commandSummary(toolInput any) string { + input, ok := toolInput.(map[string]any) + if !ok { + return "" + } + cmd, ok := input["command"].(string) + if !ok { + return "" + } + cmd = strings.Join(strings.Fields(cmd), " ") + runes := []rune(cmd) + if len(runes) <= evidenceCommandRunes { + return cmd + } + return strings.TrimSpace(string(runes[:evidenceCommandRunes])) + "..." +} + func summarizeEvidenceText(text string) string { text = strings.TrimSpace(text) if text == "" { diff --git a/internal/trace/analysis_test.go b/internal/trace/analysis_test.go index 800af60..d840a9c 100644 --- a/internal/trace/analysis_test.go +++ b/internal/trace/analysis_test.go @@ -254,7 +254,7 @@ func TestBuildOutlineAndRenderText(t *testing.T) { }, } - outline := BuildOutline(Analyze(session)) + outline := BuildOutline(Analyze(session), DefaultHeadlineWidth) if outline.Kind != OutlineKind { t.Fatalf("kind: %q", outline.Kind) } @@ -287,6 +287,43 @@ func TestBuildOutlineAndRenderText(t *testing.T) { } } +// TestBuildOutlineWidthControl: the headline cap is a parameter, not a +// constant — 0 disables truncation (the JSON consumers' escape hatch +// that previously required --full), and the cap applies identically to +// turn user text and step headlines. +func TestBuildOutlineWidthControl(t *testing.T) { + now := time.Now() + long := strings.TrimSpace(strings.Repeat("alpha beta ", 30)) // 329 runes + session := &parser.Session{ + ID: "width", + StartTime: now, + RootMessages: []*parser.Message{ + {UUID: "u1", Kind: parser.KindUserPrompt, Type: "user", Timestamp: now, + Content: []parser.ContentBlock{{Type: "text", Text: long}}}, + {UUID: "a1", Kind: parser.KindAssistant, Type: "assistant", Timestamp: now.Add(time.Minute), + Content: []parser.ContentBlock{{Type: "text", Text: long}}}, + }, + } + result := Analyze(session) + + def := BuildOutline(result, DefaultHeadlineWidth).Turns[0] + if !strings.HasSuffix(def.UserText, "...") || len([]rune(def.UserText)) > DefaultHeadlineWidth+3 { + t.Fatalf("default width must truncate: %d runes", len([]rune(def.UserText))) + } + + full := BuildOutline(result, 0).Turns[0] + if full.UserText != long || full.Steps[0].Headline != long { + t.Fatalf("width 0 must not truncate: user=%d step=%d runes", + len([]rune(full.UserText)), len([]rune(full.Steps[0].Headline))) + } + + narrow := BuildOutline(result, 20).Turns[0] + if len([]rune(narrow.UserText)) > 23 || len([]rune(narrow.Steps[0].Headline)) > 23 { + t.Fatalf("width 20 must cap both headline kinds: user=%q step=%q", + narrow.UserText, narrow.Steps[0].Headline) + } +} + // TestAnalyzeTokenSplitAndAgentCost is the cost-auditability contract: // cache tokens dominate real spend, so every turn must carry the full // split, and sidechain spend must land in the total instead of @@ -340,7 +377,7 @@ func TestAnalyzeTokenSplitAndAgentCost(t *testing.T) { result.Stats.CacheReadTokens, result.Stats.CacheCreateTokens) } - outline := BuildOutline(result) + outline := BuildOutline(result, DefaultHeadlineWidth) ot := outline.Turns[0] if ot.CacheReadTokens != 90_000 || ot.CacheCreateTokens != 45_000 || ot.AgentsCostUSD != 0.40 { t.Fatalf("outline turn split lost: %+v", ot) @@ -408,3 +445,61 @@ func TestAnalyzeAttributesResultErrorsToCallEvidence(t *testing.T) { t.Fatalf("failed evidence count %d must equal turn.Errors %d", len(failed), turn.Errors) } } + +// TestAnalyzeMutationEvidenceCarriesCommandSummary: paths answer "did +// this step touch the workspace", the summary answers "how" — without +// the command, auditing a Bash mutation means opening the raw JSONL. +func TestAnalyzeMutationEvidenceCarriesCommandSummary(t *testing.T) { + now := time.Now() + session := &parser.Session{ + ID: "cmd-summary", + StartTime: now, + RootMessages: []*parser.Message{ + {UUID: "u1", Kind: parser.KindUserPrompt, Type: "user", Timestamp: now, + Content: []parser.ContentBlock{{Type: "text", Text: "build it"}}}, + {UUID: "a1", Kind: parser.KindAssistant, Type: "assistant", Timestamp: now.Add(time.Minute), + Content: []parser.ContentBlock{ + {Type: "text", Text: "Building and editing."}, + {Type: "tool_use", ToolName: "Bash", ToolID: "t_bash", ToolInput: map[string]any{"command": "go test ./... > /w/out.log 2>&1"}}, + {Type: "tool_use", ToolName: "Edit", ToolID: "t_edit", ToolInput: map[string]any{"file_path": "/w/a.go"}}, + {Type: "tool_use", ToolName: "Bash", ToolID: "t_fail", ToolInput: map[string]any{"command": "make lint"}}, + }}, + {UUID: "r1", Kind: parser.KindToolResult, Type: "user", Timestamp: now.Add(2 * time.Minute), + Content: []parser.ContentBlock{{Type: "tool_result", ToolID: "t_fail", IsError: true}}}, + }, + } + + turn := Analyze(session).Turns[0] + summaries := map[string]string{} + for _, step := range turn.Steps { + for _, m := range step.Mutations { + summaries[m.ToolID] = m.Summary + } + } + if summaries["t_bash"] != "go test ./... > /w/out.log 2>&1" { + t.Fatalf("bash mutation summary: %q", summaries["t_bash"]) + } + if summaries["t_fail"] != "make lint" { + t.Fatalf("failed bash summary: %q", summaries["t_fail"]) + } + if summaries["t_edit"] != "" { + t.Fatalf("edit carries no command, summary must be empty: %q", summaries["t_edit"]) + } +} + +func TestCommandSummaryCollapsesAndBounds(t *testing.T) { + if got := commandSummary(map[string]any{"command": "git commit -m \"$(cat <<'EOF'\nsubject line\n\nbody\nEOF\n)\""}); strings.ContainsAny(got, "\n\t") { + t.Fatalf("summary must be one line: %q", got) + } + long := strings.Repeat("x", 5000) + got := commandSummary(map[string]any{"command": long}) + if len([]rune(got)) > evidenceCommandRunes+3 || !strings.HasSuffix(got, "...") { + t.Fatalf("summary must be bounded with ellipsis, got %d runes", len([]rune(got))) + } + if commandSummary(map[string]any{"file_path": "/w/a.go"}) != "" { + t.Fatal("non-command input must yield empty summary") + } + if commandSummary(nil) != "" { + t.Fatal("nil input must yield empty summary") + } +} diff --git a/internal/trace/branches_test.go b/internal/trace/branches_test.go index ebb3d95..fea7183 100644 --- a/internal/trace/branches_test.go +++ b/internal/trace/branches_test.go @@ -60,7 +60,7 @@ func TestAnalyzeMarksBranchSiblings(t *testing.T) { t.Fatal("active turns must not be marked superseded") } - text := RenderOutlineText(BuildOutline(result)) + text := RenderOutlineText(BuildOutline(result, DefaultHeadlineWidth)) if !strings.Contains(text, "2 turns (+1 superseded)") { t.Fatalf("header must count active turns and disclose the branch:\n%s", text) } diff --git a/internal/trace/outline.go b/internal/trace/outline.go index 0a5b07b..5c271fb 100644 --- a/internal/trace/outline.go +++ b/internal/trace/outline.go @@ -7,12 +7,17 @@ import ( "time" ) -const outlineHeadlineRunes = 160 +// DefaultHeadlineWidth caps outline headlines, in runes. `ccx trace +// --width N` overrides it; 0 disables truncation entirely. +const DefaultHeadlineWidth = 160 // BuildOutline reduces a full trace to its always-fits skeleton: // every turn and step headline plus rollup numbers. This is what // consumers read FIRST; they drill into specific turns afterwards. -func BuildOutline(result *TraceResult) *Outline { +// width caps headline length in runes; <= 0 means untruncated. The +// cap applies to JSON output too — skill/script consumers see the +// same headlines humans do. +func BuildOutline(result *TraceResult, width int) *Outline { if result == nil { return &Outline{Kind: OutlineKind} } @@ -27,7 +32,7 @@ func BuildOutline(result *TraceResult) *Outline { ot := OutlineTurn{ Index: turn.Index, Start: turn.Start, - UserText: headline(turn.UserText), + UserText: headline(turn.UserText, width), IsCommand: turn.IsCommand, CommandName: turn.CommandName, Superseded: turn.Superseded, @@ -53,7 +58,7 @@ func BuildOutline(result *TraceResult) *Outline { for _, step := range turn.Steps { os := OutlineStep{ Index: step.Index, - Headline: headline(step.Narration), + Headline: headline(step.Narration, width), Edits: len(step.FilesEdited), Errors: step.Errors, Agents: len(step.Sidechains), @@ -69,9 +74,9 @@ func BuildOutline(result *TraceResult) *Outline { return outline } -// headline reduces evidence text to its first meaningful line, -// capped for outline display. -func headline(text string) string { +// headline reduces evidence text to its first meaningful line, capped +// at width runes for outline display (<= 0 = uncapped). +func headline(text string, width int) string { text = strings.TrimSpace(text) if text == "" { return "" @@ -79,11 +84,15 @@ func headline(text string) string { if idx := strings.IndexByte(text, '\n'); idx > 0 { text = text[:idx] } - runes := []rune(strings.TrimSpace(text)) - if len(runes) > outlineHeadlineRunes { - return strings.TrimSpace(string(runes[:outlineHeadlineRunes])) + "..." + text = strings.TrimSpace(text) + if width <= 0 { + return text + } + runes := []rune(text) + if len(runes) > width { + return strings.TrimSpace(string(runes[:width])) + "..." } - return string(runes) + return text } func shortSHAs(shas []string) []string { diff --git a/internal/trace/types.go b/internal/trace/types.go index 2ea296b..7adbe08 100644 --- a/internal/trace/types.go +++ b/internal/trace/types.go @@ -111,15 +111,20 @@ type Step struct { } type ToolCallEvidence struct { - MessageID string `json:"message_id,omitempty"` - ToolID string `json:"tool_id,omitempty"` - Name string `json:"name"` - Timestamp time.Time `json:"timestamp,omitempty"` - Paths []string `json:"paths,omitempty"` - MutationCapable bool `json:"mutation_capable"` - MutatesWorkspace bool `json:"mutates_workspace"` - Reads bool `json:"reads"` - IsError bool `json:"is_error,omitempty"` + MessageID string `json:"message_id,omitempty"` + ToolID string `json:"tool_id,omitempty"` + Name string `json:"name"` + Timestamp time.Time `json:"timestamp,omitempty"` + // Summary is a bounded one-line excerpt of what the call ran, for + // command-carrying tools (Bash and provider dialects normalized to + // it). Paths answer "did it touch the workspace"; Summary answers + // "how" without opening the raw JSONL. + Summary string `json:"summary,omitempty"` + Paths []string `json:"paths,omitempty"` + MutationCapable bool `json:"mutation_capable"` + MutatesWorkspace bool `json:"mutates_workspace"` + Reads bool `json:"reads"` + IsError bool `json:"is_error,omitempty"` } type Sidechain struct { @@ -139,7 +144,11 @@ type Sidechain struct { } type GitCorrelation struct { - RepoRoot string `json:"repo_root,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + // ResolvedFrom records how RepoRoot was found: "session_cwd" (the + // session's recorded cwd) or "process_cwd" (fallback — normal when + // the session was recorded under a path this host doesn't have). + ResolvedFrom string `json:"resolved_from,omitempty"` Branch string `json:"branch,omitempty"` Head string `json:"head,omitempty"` Dirty bool `json:"dirty"`