Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 49 additions & 2 deletions internal/cmd/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
}
Expand Down
43 changes: 27 additions & 16 deletions internal/cmd/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ var (
traceJSON bool
traceTurn int
traceFull bool
traceWidth int
)

func init() {
Expand All @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/trace/active_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
28 changes: 28 additions & 0 deletions internal/trace/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 == "" {
Expand Down
99 changes: 97 additions & 2 deletions internal/trace/analysis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
}
2 changes: 1 addition & 1 deletion internal/trace/branches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading