#provenance #claude #toolpath #audit

toolpath-claude

Derive Toolpath provenance documents from Claude conversation logs

10 releases (4 breaking)

Uses new Rust 2024

new 0.6.2 Mar 6, 2026
0.6.1 Mar 5, 2026
0.4.0 Feb 26, 2026
0.3.0 Feb 25, 2026
0.1.2 Feb 16, 2026

#1526 in Development tools

27 downloads per month
Used in toolpath-cli

Apache-2.0

340KB
6.5K SLoC

toolpath-claude

Derive Toolpath provenance documents from Claude conversation logs.

When Claude Code writes your code, the conversation — the reasoning, the tool calls, the abandoned approaches — is the provenance. This crate reads those conversations directly and maps them to Toolpath documents so every AI-assisted change has a traceable origin.

Overview

Reads Claude Code conversation data from ~/.claude/projects/ and provides:

  • Conversation reading: Parse JSONL conversation files into typed structures
  • Query: Filter and search conversation entries by role, tool use, text content
  • Derivation: Map conversations to Toolpath Path documents
  • Watching: Monitor conversation files for live updates (feature-gated)

Derivation

Convert Claude conversations into Toolpath documents:

use toolpath_claude::{ClaudeConvo, derive::{DeriveConfig, derive_path}};

let manager = ClaudeConvo::new();
let convo = manager.read_conversation("/Users/alex/project", "session-uuid")?;

let config = DeriveConfig::default();
let path = derive_path(&convo, &config);
# Ok::<(), Box<dyn std::error::Error>>(())

Mapping

Claude concept Toolpath concept
Session (JSONL file) Path
Project path path.base.uri as file:///...
User message Step with actor: "human:user"
Assistant message Step with actor: "agent:{model}"
Tool use (Write/Edit) change entry keyed by file path
Assistant text meta.intent
Sidechain entries Steps parented to branch point

Reading conversations

use toolpath_claude::ClaudeConvo;

let manager = ClaudeConvo::new();

// List projects
let projects = manager.list_projects()?;

// Read a conversation
let convo = manager.read_conversation("/Users/alex/project", "session-uuid")?;
println!("{} entries", convo.entries.len());

// Most recent conversation
let latest = manager.most_recent_conversation("/Users/alex/project")?;

// Search
let matches = manager.find_conversations_with_text("/Users/alex/project", "authentication")?;
# Ok::<(), Box<dyn std::error::Error>>(())

Querying

use toolpath_claude::{ConversationQuery, MessageRole};

let query = ConversationQuery::new(&convo);
let user_msgs = query.by_role(MessageRole::User);
let tool_uses = query.tool_uses_by_name("Write");
let errors = query.errors();
let matches = query.contains_text("authentication");

Watching

With the watcher feature (enabled by default):

use toolpath_claude::{AsyncConversationWatcher, WatcherConfig};

let config = WatcherConfig::new("/Users/alex/project", "session-uuid");
let mut watcher = AsyncConversationWatcher::new(config)?;
let handle = watcher.start().await?;

// New entries arrive via the handle
while let Some(entries) = handle.recv().await {
    for entry in entries {
        println!("{}: {:?}", entry.uuid, entry.message);
    }
}

Feature flags

Feature Default Description
watcher yes Filesystem watching via notify + tokio

Provider-agnostic usage

This crate implements toolpath_convo::ConversationProvider, so consumers can code against the provider-agnostic types instead of Claude-specific structures.

Tool results are automatically assembled across entry boundaries — Claude's JSONL writes tool invocations and their results as separate entries, but load_conversation pairs them by tool_use_id so ToolInvocation.result is populated and tool-result-only user entries are absorbed (no phantom empty turns):

use toolpath_claude::ClaudeConvo;
use toolpath_convo::ConversationProvider;

let provider = ClaudeConvo::new();
let view = provider.load_conversation("/path/to/project", "session-id")?;

for turn in &view.turns {
    println!("[{}] {}: {}", turn.timestamp, turn.role, turn.text);
    for tool_use in &turn.tool_uses {
        if let Some(result) = &tool_use.result {
            println!("  {} -> {}", tool_use.name, if result.is_error { "error" } else { "ok" });
        }
    }
}

The ConversationWatcher trait impl emits WatcherEvent::Turn eagerly and follows up with WatcherEvent::TurnUpdated when tool results arrive:

use toolpath_convo::{ConversationWatcher, WatcherEvent};

for event in watcher.poll()? {
    match &event {
        WatcherEvent::Turn(turn) => ui.add_turn(turn),
        WatcherEvent::TurnUpdated(turn) => ui.replace_turn(turn),
        WatcherEvent::Progress { kind, data } => {
            match kind.as_str() {
                "session_rotated" => ui.notify_rotation(&data["from"], &data["to"]),
                _ => {
                    // Provider-specific progress data under data["claude"]
                    if let Some(claude) = data.get("claude") {
                        ui.show_progress(kind, claude);
                    }
                }
            }
        }
    }
}

Enrichment

Beyond basic conversation reading, the provider populates toolpath-convo enrichment fields:

Tool classification — Claude Code tool names are mapped to ToolCategory:

Claude Code tool ToolCategory
Read FileRead
Glob, Grep FileSearch
Write, Edit, NotebookEdit FileWrite
Bash Shell
WebFetch, WebSearch Network
Task Delegation

Unrecognized tools get category: None — consumers still have name and input.

Environment context — each turn's EnvironmentSnapshot is populated from the log entry's cwd (working directory) and git_branch (VCS branch).

Delegation trackingTask tool invocations are extracted as DelegatedWork on the parent turn, with agent_id, prompt, and result populated. Sub-agent turns are stored in separate session files and not yet cross-referenced (turns is empty).

Token usage — per-turn TokenUsage includes cache_read_tokens and cache_write_tokens from Claude's prompt caching. ConversationView.total_usage sums all per-turn token counts (input, output, cache read, cache write) into a single aggregate.

Session summaryConversationView.provider_id is "claude-code". ConversationView.files_changed lists all files mutated during the session (deduplicated, first-touch order), derived from FileWrite-categorized tool inputs.

Provider-specific metadata — Claude log entries often carry extra fields (e.g. subtype, data) that don't map to the common Turn schema. These are forwarded into Turn.extra["claude"] so trait-only consumers can access them without importing Claude-specific types:

// State inference from provider metadata
if let Some(claude) = turn.extra.get("claude") {
    if claude.get("subtype").and_then(|v| v.as_str()) == Some("init") {
        // This is a session initialization entry
    }
}

For WatcherEvent::Progress events, the full entry payload is similarly available under data["claude"] — carrying fields like data.type, data.hookName, data.agentId, and data.message.

See toolpath-convo for the full trait and type definitions.

Part of Toolpath

This crate is part of the Toolpath workspace. See also:

Dependencies

~1–14MB
~113K SLoC