diff --git a/CHANGELOG.md b/CHANGELOG.md index 2780848..7e62e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to the Toolpath workspace are documented here. +## 0.5.0 — toolpath-convo / 0.6.2 — toolpath-claude + +### toolpath-convo 0.5.0 + +- Added `WatcherEvent::as_turn()` — returns the `Turn` payload for both `Turn` and `TurnUpdated` variants +- Added `WatcherEvent::as_progress()` — returns `(kind, data)` for `Progress` events +- Added `WatcherEvent::is_update()` — returns `true` only for `TurnUpdated` +- Added `WatcherEvent::turn_id()` — returns the turn ID for turn-carrying variants +- Added dispatch loop example to `WatcherEvent` rustdoc + +### toolpath-claude 0.6.2 + +- `to_turn()` now populates `Turn.extra["claude"]` with provider-specific metadata from `ConversationEntry.extra` (e.g. `subtype`, `data`), enabling trait-only consumers to access state-inference signals without importing provider types +- `WatcherEvent::Progress` events now include the full entry payload under `data["claude"]`, carrying fields like `data.type`, `data.hookName`, `data.agentId`, and `data.message` that were previously discarded +- Both changes are additive — previously-empty fields are now populated; no existing behavior changes +- Thanks to the crabcity maintainers for the detailed gap analysis + ## 0.6.1 — toolpath-claude ### toolpath-claude 0.6.1 diff --git a/CLAUDE.md b/CLAUDE.md index effe3df..49aea45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ crates/ toolpath-dot/ # Graphviz DOT rendering toolpath-cli/ # unified CLI (binary: path) schema/toolpath.schema.json # JSON Schema for the format -examples/*.json # 11 example documents (step, path, graph) +examples/*.json # 12 example documents (step, path, graph) RFC.md # full format specification FAQ.md # design rationale, FAQ, and open questions ``` @@ -123,3 +123,4 @@ Build the site after changes: `cd site && pnpm run build` (should produce 7 page - The git derivation (`toolpath-git`) uses `git2` (libgit2 bindings), not shelling out to git - Claude conversation data lives in `~/.claude/projects/` as JSONL files; `toolpath-claude` reads these directly - `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on context overflow; `read_conversation` merges segments, `list_conversations` returns chain heads. `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. +- Provider-specific extras convention: `Turn.extra` and `WatcherEvent::Progress.data` use provider-namespaced keys (e.g. `extra["claude"]`). `toolpath-claude` populates `Turn.extra["claude"]` from `ConversationEntry.extra` and `Progress.data["claude"]` from the full entry payload. This lets trait-only consumers access provider metadata (like `subtype` for state inference) without importing provider types. diff --git a/Cargo.lock b/Cargo.lock index 28a5f69..c9bf6d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,7 +1213,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "chrono", @@ -1250,7 +1250,7 @@ dependencies = [ [[package]] name = "toolpath-convo" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 872c4c1..874ef02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ license = "Apache-2.0" [workspace.dependencies] toolpath = { version = "0.1.5", path = "crates/toolpath" } -toolpath-convo = { version = "0.4.0", path = "crates/toolpath-convo" } +toolpath-convo = { version = "0.5.0", path = "crates/toolpath-convo" } toolpath-git = { version = "0.1.3", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.6.1", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.6.2", path = "crates/toolpath-claude", default-features = false } toolpath-dot = { version = "0.1.2", path = "crates/toolpath-dot" } serde = { version = "1.0", features = ["derive"] } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index c943e43..308133a 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.6.1" +version = "0.6.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/README.md b/crates/toolpath-claude/README.md index 7fa75a8..5cfdc2f 100644 --- a/crates/toolpath-claude/README.md +++ b/crates/toolpath-claude/README.md @@ -136,10 +136,20 @@ 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 { .. } => {} + 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); + } + } + } + } } } ``` @@ -179,6 +189,24 @@ cache read, cache write) into a single aggregate. `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: + +```rust,ignore +// 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`](https://crates.io/crates/toolpath-convo) for the full trait and type definitions. ## Part of Toolpath diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index 4b73125..a24ad76 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -610,14 +610,14 @@ mod tests { ).unwrap(); // session-b: successor of a (bridge entry points to a) - let b = vec![ + let b = [ r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#, r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#, ]; fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap(); // session-c: successor of b - let c = vec![ + let c = [ r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#, r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#, ]; diff --git a/crates/toolpath-claude/src/provider.rs b/crates/toolpath-claude/src/provider.rs index 349ca93..e9370f0 100644 --- a/crates/toolpath-claude/src/provider.rs +++ b/crates/toolpath-claude/src/provider.rs @@ -5,6 +5,8 @@ //! pairs them by `tool_use_id` so consumers get complete `Turn` values //! with `ToolInvocation.result` populated. +use std::collections::HashMap; + use crate::ClaudeConvo; use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole}; #[cfg(any(feature = "watcher", test))] @@ -82,6 +84,17 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn { let delegations = extract_delegations(&tool_uses); + let extra = if entry.extra.is_empty() { + HashMap::new() + } else { + let mut map = HashMap::new(); + map.insert( + "claude".to_string(), + serde_json::to_value(&entry.extra).unwrap_or_default(), + ); + map + }; + Turn { id: entry.uuid.clone(), parent_id: entry.parent_uuid.clone(), @@ -95,7 +108,7 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn { token_usage, environment, delegations, - extra: Default::default(), + extra, } } @@ -280,13 +293,19 @@ fn extract_files_changed(turns: &[Turn]) -> Vec { fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent { match entry_to_turn(entry) { Some(turn) => WatcherEvent::Turn(Box::new(turn)), - None => WatcherEvent::Progress { - kind: entry.entry_type.clone(), - data: serde_json::json!({ + None => { + let mut data = serde_json::json!({ "uuid": entry.uuid, "timestamp": entry.timestamp, - }), - }, + }); + if !entry.extra.is_empty() { + data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default(); + } + WatcherEvent::Progress { + kind: entry.entry_type.clone(), + data, + } + } } } @@ -458,7 +477,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#, r#"{"uuid":"uuid-2","type":"assistant","parentUuid":"uuid-1","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll fix that."},{"type":"thinking","thinking":"The bug is in auth"},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"src/main.rs"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}"#, r#"{"uuid":"uuid-3","type":"user","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() { println!(\"hello\"); }","is_error":false}]}}"#, @@ -553,7 +572,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"/nonexistent"}}],"stop_reason":"tool_use"}}"#, r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"File not found","is_error":true}]}}"#, @@ -578,7 +597,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading both..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.rs"}},{"type":"tool_use","id":"t2","name":"Read","input":{"path":"b.rs"}}]}}"#, r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"file a contents","is_error":false},{"type":"tool_result","tool_use_id":"t2","content":"file b contents","is_error":false}]}}"#, @@ -607,7 +626,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#, ]; @@ -631,7 +650,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#, ]; @@ -731,7 +750,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#, r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#, ]; @@ -766,7 +785,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#, r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() {}","is_error":false}]}}"#, @@ -820,7 +839,7 @@ mod tests { fs::create_dir_all(&project_dir).unwrap(); // Start with just the user message and assistant tool use - let entries_phase1 = vec![ + let entries_phase1 = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#, ]; @@ -998,7 +1017,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#, ]; @@ -1026,7 +1045,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":200,"cache_read_input_tokens":500}}}"#, ]; @@ -1070,7 +1089,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Editing..."},{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"src/main.rs","content":"fn main() {}"}},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"src/lib.rs","old_string":"a","new_string":"b"}}]}}"#, r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false},{"type":"tool_result","tool_use_id":"t2","content":"ok","is_error":false}]}}"#, @@ -1094,7 +1113,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Delegating..."},{"type":"tool_use","id":"task-1","name":"Task","input":{"prompt":"Find the authentication bug","subagent_type":"Explore"}}]}}"#, r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-1","content":"Found the bug in auth.rs line 42","is_error":false}]}}"#, @@ -1119,6 +1138,70 @@ mod tests { ); } + // ── Provider-specific extras (Turn.extra["claude"]) ───────────── + + #[test] + fn test_turn_extra_populated_from_entry() { + let entry: ConversationEntry = serde_json::from_str( + r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","subtype":"init","message":{"role":"user","content":"hello"}}"#, + ) + .unwrap(); + let turn = to_turn(&entry).unwrap(); + let claude = turn.extra.get("claude").expect("extra[\"claude\"] missing"); + assert_eq!(claude["subtype"], "init"); + } + + #[test] + fn test_turn_extra_empty_when_no_extras() { + let entry: ConversationEntry = serde_json::from_str( + r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#, + ) + .unwrap(); + let turn = to_turn(&entry).unwrap(); + assert!(turn.extra.is_empty()); + } + + #[test] + fn test_progress_data_enriched_with_extras() { + let entry: ConversationEntry = serde_json::from_str( + r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#, + ) + .unwrap(); + let event = entry_to_watcher_event(&entry); + match event { + WatcherEvent::Progress { kind, data } => { + assert_eq!(kind, "progress"); + assert_eq!(data["uuid"], "u1"); + assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z"); + let claude = &data["claude"]; + assert_eq!(claude["data"]["type"], "hook_progress"); + assert_eq!(claude["data"]["hookName"], "pre-commit"); + } + other => panic!( + "Expected Progress, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + #[test] + fn test_progress_data_no_claude_key_when_no_extras() { + let entry: ConversationEntry = serde_json::from_str( + r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#, + ) + .unwrap(); + let event = entry_to_watcher_event(&entry); + match event { + WatcherEvent::Progress { data, .. } => { + assert!(data.get("claude").is_none()); + } + other => panic!( + "Expected Progress, got {:?}", + std::mem::discriminant(&other) + ), + } + } + #[test] fn test_no_delegations_for_non_task_tools() { let (_temp, provider) = setup_provider(); @@ -1140,14 +1223,14 @@ mod tests { fs::create_dir_all(&project_dir).unwrap(); // Session A: original conversation - let entries_a = vec![ + let entries_a = [ r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#, r#"{"uuid":"a2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"session-a","message":{"role":"assistant","content":"I'll fix that.","model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50}}}"#, ]; fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap(); // Session B: continuation with bridge entry - let entries_b = vec![ + let entries_b = [ // Bridge entry: session_id points back to session-a r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#, // Real entries in session-b @@ -1201,7 +1284,7 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let entries = vec![ + let entries = [ r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#, r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#, ]; @@ -1265,7 +1348,7 @@ mod tests { assert!(matches!(&events[0], WatcherEvent::Turn(_))); // Create successor session-b - let entries_b = vec![ + let entries_b = [ r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#, r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#, ]; diff --git a/crates/toolpath-claude/src/watcher.rs b/crates/toolpath-claude/src/watcher.rs index 27a610a..6640993 100644 --- a/crates/toolpath-claude/src/watcher.rs +++ b/crates/toolpath-claude/src/watcher.rs @@ -446,7 +446,7 @@ mod tests { assert_eq!(watcher.session_id(), "session-a"); // Now create session-b with a bridge entry pointing to session-a - let entries_b = vec![ + let entries_b = [ r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#, r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New content"}}"#, ]; @@ -491,7 +491,7 @@ mod tests { assert_eq!(convo.session_id, "session-a"); // Create successor - let entries_b = vec![ + let entries_b = [ r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#, r#"{"uuid":"b1","type":"assistant","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"assistant","content":"Continued"}}"#, ]; @@ -521,7 +521,7 @@ mod tests { .unwrap(); // Session B (successor of A) - let entries_b = vec![ + let entries_b = [ r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#, r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#, ]; diff --git a/crates/toolpath-cli/src/cmd_track.rs b/crates/toolpath-cli/src/cmd_track.rs index f3c120b..8a4d702 100644 --- a/crates/toolpath-cli/src/cmd_track.rs +++ b/crates/toolpath-cli/src/cmd_track.rs @@ -1436,18 +1436,19 @@ mod tests { inherit_from: Option, ) { let (path_doc, mut state) = load_session(session_path).unwrap(); - if !state.buffer_cache.contains_key(&seq) { - state.buffer_cache.insert(seq, content.to_string()); - } - if !state.seq_to_step.contains_key(&seq) { - if let Some(ancestor) = inherit_from { - let step_id = state - .seq_to_step - .get(&ancestor) - .cloned() - .unwrap_or_default(); - state.seq_to_step.insert(seq, step_id); - } + state + .buffer_cache + .entry(seq) + .or_insert_with(|| content.to_string()); + if !state.seq_to_step.contains_key(&seq) + && let Some(ancestor) = inherit_from + { + let step_id = state + .seq_to_step + .get(&ancestor) + .cloned() + .unwrap_or_default(); + state.seq_to_step.insert(seq, step_id); } save_session(session_path, &path_doc, &state).unwrap(); } diff --git a/crates/toolpath-cli/tests/integration.rs b/crates/toolpath-cli/tests/integration.rs index fa1c8f1..f8ac7d8 100644 --- a/crates/toolpath-cli/tests/integration.rs +++ b/crates/toolpath-cli/tests/integration.rs @@ -15,6 +15,64 @@ fn cmd() -> Command { Command::cargo_bin("path").unwrap() } +// ── Git fixture ────────────────────────────────────────────────────── + +/// Creates a temporary git repo with a known commit history for testing. +/// +/// Layout (all on branch `main`): +/// commit 1: "initial commit" — creates main.rs with "fn main() {}" +/// commit 2: "fix the bug" — changes main.rs to "fn main() { fixed() }" +/// +/// Returns (temp_dir, branch_name). Temp dir must be kept alive for the +/// repo to remain on disk. +fn git_fixture() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Alice Dev").unwrap(); + config.set_str("user.email", "alice@example.com").unwrap(); + + // Commit 1 + let mut index = repo.index().unwrap(); + std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap(); + index + .add_path(std::path::Path::new("main.rs")) + .unwrap(); + index.write().unwrap(); + let tree1 = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let sig = repo.signature().unwrap(); + let oid1 = repo + .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree1, &[]) + .unwrap(); + let commit1 = repo.find_commit(oid1).unwrap(); + + // Commit 2 + std::fs::write(dir.path().join("main.rs"), "fn main() { fixed() }").unwrap(); + index + .add_path(std::path::Path::new("main.rs")) + .unwrap(); + index.write().unwrap(); + let tree2 = repo.find_tree(index.write_tree().unwrap()).unwrap(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "fix the bug", + &tree2, + &[&commit1], + ) + .unwrap(); + + // Determine the branch name (main or master depending on git config) + let head = repo.head().unwrap(); + let branch = head.shorthand().unwrap().to_string(); + + (dir, branch) +} + +// ── Validate ───────────────────────────────────────────────────────── + #[test] fn validate_valid_step() { cmd() @@ -42,24 +100,169 @@ fn validate_invalid_json() { let _ = std::fs::remove_file(&tmp_file); } +// ── Derive git ─────────────────────────────────────────────────────── + #[test] fn derive_git_produces_path() { - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join(".."); + let (dir, branch) = git_fixture(); cmd() .arg("derive") .arg("git") .arg("--repo") - .arg(&repo_root) + .arg(dir.path()) + .arg("--branch") + .arg(&branch) + .assert() + .success() + .stdout(predicate::str::contains("\"Path\"")) + .stdout(predicate::str::contains("\"head\":")) + .stdout(predicate::str::contains("\"steps\"")); +} + +#[test] +fn derive_git_has_correct_actor() { + let (dir, branch) = git_fixture(); + + let output = cmd() + .arg("derive") + .arg("git") + .arg("--repo") + .arg(dir.path()) + .arg("--branch") + .arg(&branch) + .arg("--pretty") + .output() + .unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let path = &json["Path"]; + + // Actor is derived from git author email username (alice@example.com → alice) + let step = &path["steps"][0]; + assert_eq!(step["step"]["actor"], "human:alice"); + + // Actor metadata in path.meta.actors + let actors = &path["meta"]["actors"]; + let alice = &actors["human:alice"]; + assert_eq!(alice["name"], "Alice Dev"); + assert_eq!(alice["identities"][0]["id"], "alice@example.com"); +} + +#[test] +fn derive_git_has_change_with_diff() { + let (dir, branch) = git_fixture(); + + let output = cmd() + .arg("derive") + .arg("git") + .arg("--repo") + .arg(dir.path()) + .arg("--branch") + .arg(&branch) + .arg("--pretty") + .output() + .unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let step = &json["Path"]["steps"][0]; + + // The step should have a change for main.rs with a raw diff + let change = &step["change"]["main.rs"]; + let raw = change["raw"].as_str().unwrap(); + assert!(raw.contains("-fn main() {}"), "diff should show old content"); + assert!( + raw.contains("+fn main() { fixed() }"), + "diff should show new content" + ); +} + +#[test] +fn derive_git_has_intent_from_commit_message() { + let (dir, branch) = git_fixture(); + + let output = cmd() + .arg("derive") + .arg("git") + .arg("--repo") + .arg(dir.path()) + .arg("--branch") + .arg(&branch) + .arg("--pretty") + .output() + .unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let step = &json["Path"]["steps"][0]; + + // meta.intent is the commit message + assert_eq!(step["meta"]["intent"], "fix the bug"); +} + +#[test] +fn derive_git_has_base_uri() { + let (dir, branch) = git_fixture(); + + let output = cmd() + .arg("derive") + .arg("git") + .arg("--repo") + .arg(dir.path()) + .arg("--branch") + .arg(&branch) + .arg("--pretty") + .output() + .unwrap(); + assert!(output.status.success()); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let base = &json["Path"]["path"]["base"]; + + // base.uri should be a file:// URL pointing to the repo + let uri = base["uri"].as_str().unwrap(); + assert!(uri.starts_with("file://"), "Expected file:// URI, got {}", uri); + + // base.ref should be a commit hash (40 hex chars) + let git_ref = base["ref"].as_str().unwrap(); + assert_eq!(git_ref.len(), 40); + assert!(git_ref.chars().all(|c| c.is_ascii_hexdigit())); +} + +// ── Derive → validate roundtrip ───────────────────────────────────── + +#[test] +fn derive_git_validate_roundtrip() { + let (dir, branch) = git_fixture(); + let tmp_file = std::env::temp_dir().join("toolpath-integration-roundtrip.json"); + + let derive_output = cmd() + .arg("derive") + .arg("git") + .arg("--repo") + .arg(dir.path()) .arg("--branch") - .arg("main") + .arg(&branch) + .output() + .unwrap(); + assert!(derive_output.status.success()); + std::fs::write(&tmp_file, &derive_output.stdout).unwrap(); + + cmd() + .arg("validate") + .arg("--input") + .arg(&tmp_file) .assert() .success() - .stdout(predicate::str::contains("\"Path\"")); + .stdout(predicate::str::contains("Valid")); + + let _ = std::fs::remove_file(&tmp_file); } +// ── Render ─────────────────────────────────────────────────────────── + #[test] fn render_dot_from_stdin() { let input = std::fs::read_to_string(examples_dir().join("path-01-pr.json")).unwrap(); @@ -73,6 +276,8 @@ fn render_dot_from_stdin() { .stdout(predicate::str::contains("digraph")); } +// ── Query ──────────────────────────────────────────────────────────── + #[test] fn query_dead_ends() { cmd() @@ -100,6 +305,8 @@ fn query_ancestors() { .stdout(predicate::str::contains("step-004")); } +// ── Merge ──────────────────────────────────────────────────────────── + #[test] fn merge_produces_graph() { cmd() @@ -110,36 +317,3 @@ fn merge_produces_graph() { .success() .stdout(predicate::str::contains("\"Graph\"")); } - -#[test] -fn derive_git_validate_roundtrip() { - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join(".."); - - let dir = std::env::temp_dir(); - let tmp_file = dir.join("toolpath-integration-roundtrip.json"); - - let derive_output = cmd() - .arg("derive") - .arg("git") - .arg("--repo") - .arg(&repo_root) - .arg("--branch") - .arg("main") - .output() - .unwrap(); - - assert!(derive_output.status.success()); - std::fs::write(&tmp_file, &derive_output.stdout).unwrap(); - - cmd() - .arg("validate") - .arg("--input") - .arg(&tmp_file) - .assert() - .success() - .stdout(predicate::str::contains("Valid")); - - let _ = std::fs::remove_file(&tmp_file); -} diff --git a/crates/toolpath-convo/Cargo.toml b/crates/toolpath-convo/Cargo.toml index adb7f05..65770dc 100644 --- a/crates/toolpath-convo/Cargo.toml +++ b/crates/toolpath-convo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-convo" -version = "0.4.0" +version = "0.5.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-convo/README.md b/crates/toolpath-convo/README.md index 4cff961..37e171b 100644 --- a/crates/toolpath-convo/README.md +++ b/crates/toolpath-convo/README.md @@ -24,7 +24,7 @@ Write your conversation analysis once, swap providers without changing a line. | `TokenUsage` | Input/output/cache token counts | | `EnvironmentSnapshot` | Working directory and VCS branch/revision at time of a turn | | `DelegatedWork` | A sub-agent delegation: prompt, nested turns, result | -| `WatcherEvent` | A `Turn` (new), `TurnUpdated` (enriched with tool results), or `Progress` event | +| `WatcherEvent` | A `Turn` (new), `TurnUpdated` (enriched with tool results), or `Progress` event — with `as_turn()`, `as_progress()`, `is_update()`, `turn_id()` helpers for ergonomic dispatch | **Traits** define how providers expose their data: @@ -101,6 +101,40 @@ let writes: Vec<_> = turn.tool_uses.iter() .collect(); ``` +## Watching + +Dispatch on `WatcherEvent` with `match` — three variants, exhaustive: + +```rust,ignore +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 } => ui.show_progress(kind, data), + } +} +``` + +Convenience methods (`as_turn()`, `as_progress()`, `is_update()`, `turn_id()`) +are available for cases where the distinction between `Turn` and `TurnUpdated` +collapses — e.g. a formatting pipeline that takes a turn + flag: + +```rust,ignore +// When Turn/TurnUpdated go through the same path: +if let Some(turn) = event.as_turn() { + format_turn(turn, event.is_update()); +} + +// Keying/dedup without matching two variants: +if let Some(id) = event.turn_id() { + seen.insert(id); +} +``` + +Provider-specific metadata lives in `Turn.extra`, namespaced by provider (e.g. `turn.extra["claude"]`). This keeps the common schema clean while giving consumers opt-in access to provider internals. + ## Provider implementations | Provider | Crate | diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index 7f6d93c..22a005d 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -186,6 +186,10 @@ pub struct Turn { pub delegations: Vec, /// Provider-specific data that doesn't fit the common schema. + /// + /// Providers namespace their data under a provider key (e.g. + /// `extra["claude"]` for Claude Code) to avoid collisions when + /// consumers work with multiple providers. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap, } @@ -306,6 +310,35 @@ pub struct SessionLink { // ── Events ─────────────────────────────────────────────────────────── /// Events emitted by a [`ConversationWatcher`]. +/// +/// # Dispatch +/// +/// Use `match` for exhaustive dispatch — the compiler catches new variants: +/// +/// ``` +/// use toolpath_convo::WatcherEvent; +/// +/// fn handle_events(events: &[WatcherEvent]) { +/// for event in events { +/// match event { +/// WatcherEvent::Turn(turn) => { +/// println!("new turn {}: {}", turn.id, turn.text); +/// } +/// WatcherEvent::TurnUpdated(turn) => { +/// println!("updated turn {}: {}", turn.id, turn.text); +/// } +/// WatcherEvent::Progress { kind, data } => { +/// println!("progress ({}): {}", kind, data); +/// } +/// } +/// } +/// } +/// ``` +/// +/// Convenience methods ([`as_turn`](WatcherEvent::as_turn), +/// [`turn_id`](WatcherEvent::turn_id), [`is_update`](WatcherEvent::is_update), +/// [`as_progress`](WatcherEvent::as_progress)) are useful when `Turn` and +/// `TurnUpdated` collapse into the same code path or for quick field access. #[derive(Debug, Clone)] pub enum WatcherEvent { /// A turn seen for the first time. @@ -325,6 +358,35 @@ pub enum WatcherEvent { }, } +impl WatcherEvent { + /// Returns the [`Turn`] payload for both [`Turn`](WatcherEvent::Turn) + /// and [`TurnUpdated`](WatcherEvent::TurnUpdated) variants. + pub fn as_turn(&self) -> Option<&Turn> { + match self { + WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t), + WatcherEvent::Progress { .. } => None, + } + } + + /// Returns `(kind, data)` for [`Progress`](WatcherEvent::Progress) events. + pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> { + match self { + WatcherEvent::Progress { kind, data } => Some((kind, data)), + _ => None, + } + } + + /// Returns `true` only for [`TurnUpdated`](WatcherEvent::TurnUpdated). + pub fn is_update(&self) -> bool { + matches!(self, WatcherEvent::TurnUpdated(_)) + } + + /// Returns the turn ID for turn-carrying variants. + pub fn turn_id(&self) -> Option<&str> { + self.as_turn().map(|t| t.id.as_str()) + } +} + // ── Traits ─────────────────────────────────────────────────────────── /// Trait for converting provider-specific conversation data into the @@ -545,6 +607,66 @@ mod tests { assert!(matches!(progress_event, WatcherEvent::Progress { .. })); } + #[test] + fn test_watcher_event_as_turn() { + let turn = sample_view().turns[0].clone(); + let event = WatcherEvent::Turn(Box::new(turn.clone())); + assert_eq!(event.as_turn().unwrap().id, "t1"); + + let updated = WatcherEvent::TurnUpdated(Box::new(turn)); + assert_eq!(updated.as_turn().unwrap().id, "t1"); + + let progress = WatcherEvent::Progress { + kind: "test".into(), + data: serde_json::Value::Null, + }; + assert!(progress.as_turn().is_none()); + } + + #[test] + fn test_watcher_event_as_progress() { + let progress = WatcherEvent::Progress { + kind: "hook_progress".into(), + data: serde_json::json!({"hookName": "pre-commit"}), + }; + let (kind, data) = progress.as_progress().unwrap(); + assert_eq!(kind, "hook_progress"); + assert_eq!(data["hookName"], "pre-commit"); + + let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone())); + assert!(turn.as_progress().is_none()); + } + + #[test] + fn test_watcher_event_is_update() { + let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone())); + assert!(!turn.is_update()); + + let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone())); + assert!(updated.is_update()); + + let progress = WatcherEvent::Progress { + kind: "test".into(), + data: serde_json::Value::Null, + }; + assert!(!progress.is_update()); + } + + #[test] + fn test_watcher_event_turn_id() { + let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone())); + assert_eq!(turn.turn_id(), Some("t2")); + + let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone())); + assert_eq!(updated.turn_id(), Some("t1")); + + let progress = WatcherEvent::Progress { + kind: "test".into(), + data: serde_json::Value::Null, + }; + assert!(progress.turn_id().is_none()); + } + #[test] fn test_token_usage_default() { let usage = TokenUsage::default(); @@ -675,7 +797,7 @@ mod tests { ToolCategory::Delegation, ]; for cat in variants { - let json = serde_json::to_value(&cat).unwrap(); + let json = serde_json::to_value(cat).unwrap(); let back: ToolCategory = serde_json::from_value(json).unwrap(); assert_eq!(back, cat); } diff --git a/crates/toolpath-git/src/lib.rs b/crates/toolpath-git/src/lib.rs index cc3fecb..4277331 100644 --- a/crates/toolpath-git/src/lib.rs +++ b/crates/toolpath-git/src/lib.rs @@ -893,7 +893,7 @@ mod native { let result = derive(&repo, &[default], &config).unwrap(); match result { Document::Path(path) => { - assert!(path.steps.len() >= 1); + assert!(!path.steps.is_empty()); } _ => panic!("Expected Document::Path"), } @@ -920,7 +920,7 @@ mod native { }; let path = derive_path(&repo, &spec, &config).unwrap(); - assert!(path.steps.len() >= 1); + assert!(!path.steps.is_empty()); } #[test] diff --git a/examples/path-04-exploration.json b/examples/path-04-exploration.json new file mode 100644 index 0000000..9b98d9f --- /dev/null +++ b/examples/path-04-exploration.json @@ -0,0 +1,154 @@ +{ + "Path": { + "path": { + "id": "path-explore-cli-args", + "base": { + "uri": "github:myorg/myrepo", + "ref": "main" + }, + "head": "step-004" + }, + + "steps": [ + { + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-02-10T09:00:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -1,3 +1,8 @@\n+use std::process;\n+\n fn main() {\n- println!(\"Hello, world!\");\n+ let args: Vec = std::env::args().collect();\n+ if args.len() < 2 {\n+ eprintln!(\"usage: mytool \");\n+ process::exit(1);\n+ }\n }" + } + }, + "meta": { + "intent": "Scaffold CLI entry point with basic arg check" + } + }, + { + "step": { + "id": "step-002a", + "parents": ["step-001"], + "actor": "agent:claude-code/session-exp1", + "timestamp": "2026-02-10T09:05:00Z" + }, + "change": { + "Cargo.toml": { + "raw": "@@ -6,0 +7,2 @@\n+[dependencies]\n+clap = { version = \"4\", features = [\"derive\"] }" + }, + "src/main.rs": { + "raw": "@@ -1,8 +1,15 @@\n+use clap::Parser;\n+\n+#[derive(Parser)]\n+struct Cli {\n+ command: String,\n+}\n+\n fn main() {\n- let args: Vec = std::env::args().collect();\n- if args.len() < 2 {\n- eprintln!(\"usage: mytool \");\n- process::exit(1);\n- }\n+ let cli = Cli::parse();\n+ println!(\"Running: {}\", cli.command);\n }" + } + }, + "meta": { + "intent": "Try clap derive macros (abandoned: too much codegen)" + } + }, + { + "step": { + "id": "step-002b", + "parents": ["step-001"], + "actor": "agent:claude-code/session-exp1", + "timestamp": "2026-02-10T09:08:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -1,8 +1,20 @@\n-use std::process;\n+use std::process;\n+use std::env;\n \n fn main() {\n- let args: Vec = std::env::args().collect();\n- if args.len() < 2 {\n- eprintln!(\"usage: mytool \");\n- process::exit(1);\n- }\n+ let args: Vec = env::args().collect();\n+ let cmd = args.get(1).map(|s| s.as_str());\n+ match cmd {\n+ Some(\"run\") => run(&args[2..]),\n+ Some(\"help\") | None => print_usage(),\n+ Some(other) => {\n+ eprintln!(\"unknown command: {other}\");\n+ process::exit(1);\n+ }\n+ }\n+}\n+\n+fn run(_args: &[String]) { todo!() }\n+fn print_usage() { println!(\"usage: mytool \"); }" + } + }, + "meta": { + "intent": "Try manual arg parsing with match" + } + }, + { + "step": { + "id": "step-003b", + "parents": ["step-002b"], + "actor": "tool:clippy/0.1.84", + "timestamp": "2026-02-10T09:09:00Z" + }, + "change": { + "src/main.rs": { + "structural": { + "type": "rust.rename", + "from": "_args", + "to": "args" + } + } + }, + "meta": { + "intent": "Fix clippy: unused variable prefix" + } + }, + { + "step": { + "id": "step-002c", + "parents": ["step-001"], + "actor": "agent:claude-code/session-exp1", + "timestamp": "2026-02-10T09:12:00Z" + }, + "change": { + "Cargo.toml": { + "raw": "@@ -6,0 +7,2 @@\n+[dependencies]\n+clap = \"4\"" + }, + "src/main.rs": { + "raw": "@@ -1,8 +1,18 @@\n-use std::process;\n+use clap::{Command, Arg};\n \n fn main() {\n- let args: Vec = std::env::args().collect();\n- if args.len() < 2 {\n- eprintln!(\"usage: mytool \");\n- process::exit(1);\n- }\n+ let matches = Command::new(\"mytool\")\n+ .subcommand(Command::new(\"run\")\n+ .about(\"Run the tool\")\n+ .arg(Arg::new(\"verbose\").short('v')))\n+ .subcommand(Command::new(\"help\")\n+ .about(\"Print help\"))\n+ .get_matches();\n+\n+ match matches.subcommand() {\n+ Some((\"run\", sub)) => run(sub),\n+ _ => println!(\"usage: mytool \"),\n+ }\n+}\n+\n+fn run(_sub: &clap::ArgMatches) { todo!() }" + } + }, + "meta": { + "intent": "Try clap builder API (no derive macros)" + } + }, + { + "step": { + "id": "step-003c", + "parents": ["step-002c"], + "actor": "tool:rustfmt/1.7.0", + "timestamp": "2026-02-10T09:13:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -4,6 +4,10 @@\n- let matches = Command::new(\"mytool\")\n- .subcommand(Command::new(\"run\")\n- .about(\"Run the tool\")\n- .arg(Arg::new(\"verbose\").short('v')))\n- .subcommand(Command::new(\"help\")\n- .about(\"Print help\"))\n+ let matches = Command::new(\"mytool\")\n+ .subcommand(\n+ Command::new(\"run\")\n+ .about(\"Run the tool\")\n+ .arg(Arg::new(\"verbose\").short('v')),\n+ )\n+ .subcommand(Command::new(\"help\").about(\"Print help\"))" + } + }, + "meta": { + "intent": "Auto-format" + } + }, + { + "step": { + "id": "step-004", + "parents": ["step-003b", "step-003c"], + "actor": "human:alex", + "timestamp": "2026-02-10T09:20:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -1,18 +1,25 @@\n-use clap::{Command, Arg};\n+use clap::{Arg, Command};\n+use std::env;\n \n fn main() {\n let matches = Command::new(\"mytool\")\n .subcommand(\n Command::new(\"run\")\n .about(\"Run the tool\")\n .arg(Arg::new(\"verbose\").short('v')),\n )\n .subcommand(Command::new(\"help\").about(\"Print help\"))\n .get_matches();\n \n match matches.subcommand() {\n- Some((\"run\", sub)) => run(sub),\n+ Some((\"run\", sub)) => {\n+ let verbose = sub.get_flag(\"verbose\");\n+ run(verbose);\n+ }\n _ => println!(\"usage: mytool \"),\n }\n }\n \n-fn run(_sub: &clap::ArgMatches) { todo!() }\n+fn run(verbose: bool) {\n+ if verbose { println!(\"verbose mode\"); }\n+ println!(\"running\");\n+}" + } + }, + "meta": { + "intent": "Merge builder API with manual match dispatch, wire up verbose flag" + } + } + ], + + "meta": { + "title": "Explore CLI argument parsing approaches", + "source": "agent://claude-code/session-exp1", + "actors": { + "human:alex": { + "name": "Alex", + "identities": [ + {"system": "github", "id": "alexk"} + ] + }, + "agent:claude-code/session-exp1": { + "name": "Claude Code", + "provider": "Anthropic", + "model": "claude-sonnet-4-20250514" + } + } + } + } +} diff --git a/scripts/quality_gates.sh b/scripts/quality_gates.sh new file mode 100755 index 0000000..a338b44 --- /dev/null +++ b/scripts/quality_gates.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Run quality gates for the repository. +# +# Usage: +# scripts/quality_gates.sh [--verbose] [[-]gate ...] +# +# Gates: format, clippy, test, doc, examples, site +# No args runs all gates. Prefix with - to exclude a gate. +# +# Options: +# --verbose Stream all output (useful for CI) +# +# Examples: +# scripts/quality_gates.sh # all gates +# scripts/quality_gates.sh test # just tests +# scripts/quality_gates.sh -site # everything except site build +# scripts/quality_gates.sh --verbose # all gates, full output +# + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# ── Colors (when stdout is a terminal) ──────────────────────────────────────── + +if [[ -t 1 ]]; then + _grn=$'\033[32m' _red=$'\033[31m' _bld=$'\033[1m' _dim=$'\033[2m' _rst=$'\033[0m' +else + _grn='' _red='' _bld='' _dim='' _rst='' +fi + +# ── Temp dir for captured output ────────────────────────────────────────────── + +_tmpdir=$(mktemp -d) +trap 'rm -rf "$_tmpdir"' EXIT + +# ── Gate definitions ────────────────────────────────────────────────────────── + +_all_gates=(format clippy test doc examples site) + +gate_format() { + echo "--- cargo fmt ---" + cargo fmt --all --manifest-path "$ROOT/Cargo.toml" --check 2>&1 + echo "--- prettier ---" + cd "$ROOT/site" + npx --yes prettier --check --no-color "**/*.{md,css,json,js}" 2>&1 +} + +gate_clippy() { + cargo clippy --workspace -- -D warnings 2>&1 +} + +gate_test() { + cargo test --workspace 2>&1 +} + +gate_doc() { + cargo doc --workspace --no-deps 2>&1 +} + +gate_examples() { + local failed=0 + for f in "$ROOT"/examples/*.json; do + if ! cargo run --quiet -p toolpath-cli -- validate --input "$f" 2>&1; then + failed=1 + fi + done + return $failed +} + +gate_site() { + cd "$ROOT/site" && pnpm run build 2>&1 +} + +# ── Runner ──────────────────────────────────────────────────────────────────── + +run_gate() { + local name=$1 + + if [[ $_verbose -eq 1 ]]; then + echo "${_bld}── $name ──${_rst}" + local start=$SECONDS + if "gate_$name"; then + local elapsed=$(( SECONDS - start )) + echo "${_grn}PASS${_rst}: $name (${elapsed}s)" + echo "" + return 0 + else + local elapsed=$(( SECONDS - start )) + echo "${_red}FAIL${_rst}: $name (${elapsed}s)" + echo "" + return 1 + fi + else + local logfile="$_tmpdir/$name.log" + printf "%s: " "$name" + local start=$SECONDS + if "gate_$name" > "$logfile" 2>&1; then + local elapsed=$(( SECONDS - start )) + echo "${_grn}PASS${_rst} (${elapsed}s)" + return 0 + else + local elapsed=$(( SECONDS - start )) + echo "${_red}FAIL${_rst} (${elapsed}s)" + tail -50 "$logfile" | sed 's/^/ /' + return 1 + fi + fi +} + +# ── Parse args ──────────────────────────────────────────────────────────────── + +_valid_gate() { + local name=$1 + for g in "${_all_gates[@]}"; do [[ "$name" == "$g" ]] && return 0; done + return 1 +} + +_verbose=0 +gates=() +excludes=() +for arg in "$@"; do + if [[ "$arg" == "--verbose" ]]; then + _verbose=1 + continue + elif [[ "$arg" == -* ]]; then + name="${arg#-}" + if ! _valid_gate "$name"; then + echo "Unknown gate: $name" + echo "Valid gates: ${_all_gates[*]}" + exit 2 + fi + excludes+=("$name") + else + if ! _valid_gate "$arg"; then + echo "Unknown gate: $arg" + echo "Valid gates: ${_all_gates[*]}" + exit 2 + fi + gates+=("$arg") + fi +done + +# Default: all gates +if [[ ${#gates[@]} -eq 0 ]]; then + gates=("${_all_gates[@]}") +fi + +# Apply exclusions +if [[ ${#excludes[@]} -gt 0 ]]; then + filtered=() + for g in "${gates[@]}"; do + excluded=0 + for e in "${excludes[@]}"; do + [[ "$g" == "$e" ]] && excluded=1 && break + done + [[ $excluded -eq 0 ]] && filtered+=("$g") + done + gates=("${filtered[@]}") +fi + +# ── Run gates ───────────────────────────────────────────────────────────────── + +passed=0 +total=${#gates[@]} + +echo "${_bld}Running: ${gates[*]}${_rst} ${_dim}(skip with -gate, e.g. -site)${_rst}" +echo "" + +for gate in "${gates[@]}"; do + if run_gate "$gate"; then + ((passed++)) + fi +done + +echo "" + +if [[ $passed -eq $total ]]; then + echo "${_grn}${passed}/${total} passed${_rst}" + exit 0 +else + echo "${_red}${passed}/${total} passed${_rst}" + exit 1 +fi diff --git a/site/_data/crates.json b/site/_data/crates.json index eeba9e0..89eda9b 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -9,7 +9,7 @@ }, { "name": "toolpath-convo", - "version": "0.4.0", + "version": "0.5.0", "description": "Provider-agnostic conversation types and traits", "docs": "https://docs.rs/toolpath-convo", "crate": "https://crates.io/crates/toolpath-convo", @@ -25,7 +25,7 @@ }, { "name": "toolpath-claude", - "version": "0.6.1", + "version": "0.6.2", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", diff --git a/site/eleventy.config.js b/site/eleventy.config.js index cddf1cb..7a223b0 100644 --- a/site/eleventy.config.js +++ b/site/eleventy.config.js @@ -81,15 +81,10 @@ export default function (eleventyConfig) { eleventyConfig.addGlobalData("vizExamples", () => { const examples = [ { file: "step-01-minimal.json", name: "Step: minimal" }, - { file: "step-02-agent.json", name: "Step: agent change" }, - { file: "step-03-formatter.json", name: "Step: formatter" }, - { file: "step-04-human-refinement.json", name: "Step: human refinement" }, - { file: "step-05-dead-end.json", name: "Step: dead end" }, - { file: "step-06-signed.json", name: "Step: signed" }, - { file: "step-07-merge.json", name: "Step: merge" }, { file: "path-01-pr.json", name: "Path: PR with dead end" }, { file: "path-02-local-session.json", name: "Path: local session" }, { file: "path-03-signed-pr.json", name: "Path: signed PR" }, + { file: "path-04-exploration.json", name: "Path: exploration & merge" }, { file: "graph-01-release.json", name: "Graph: release bundle" }, ]; return examples.map((e) => ({ diff --git a/site/js/visualizer.js b/site/js/visualizer.js index c4ed46d..fe442d6 100644 --- a/site/js/visualizer.js +++ b/site/js/visualizer.js @@ -19,8 +19,8 @@ // --- Examples loaded from window.__VIZ_EXAMPLES__ (injected by Eleventy) --- var VIZ_EXAMPLES = window.__VIZ_EXAMPLES__ || []; - // Default to "Path: PR with dead end" (index 7) or first available - var DEFAULT_EXAMPLE_INDEX = VIZ_EXAMPLES.length > 7 ? 7 : 0; + // Default to "Path: exploration & merge" (index 4) or first available + var DEFAULT_EXAMPLE_INDEX = VIZ_EXAMPLES.length > 4 ? 4 : 0; // --- DOM refs --- var input = document.getElementById("viz-input"); @@ -264,6 +264,9 @@ if (s.step.parents) { s.step.parents.forEach(function (pid) { var sourceId = prefix + pid; + // Don't add edge if parent node doesn't exist in graph + // (e.g. standalone Step docs referencing external parents) + if (!g.node(sourceId)) return; // Don't add edge if parent is hidden dead-end if (!showDeadEnds && ancestorSet && !ancestorSet[pid]) return;