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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to the Toolpath workspace are documented here.

## Preamble steps get real timestamps; validate enforces schema formats — 2026-07-06

Claude Code writes several headerless session-log lines with no
`timestamp` field (`last-prompt`, `ai-title`, `custom-title`; a headless
`claude -p` run produces them every time). `toolpath-claude` stamped the
steps derived from those lines with `"timestamp": ""`, which the schema
forbids (`$defs/timestamp` is `format: date-time`) but `path p validate`
silently accepted, because JSON Schema draft 2020-12 treats `format` as
annotation-only unless the validator opts in. Both ends are fixed: the
derivation now resolves a real timestamp, and validation now asserts the
formats the schema declares.

- **`toolpath-claude`** (0.12.1): a step derived from a timestamp-less
preamble line resolves its timestamp from the nearest real source — the
entry the line references (`leafUuid` / `messageId`), else the previous
preamble line, else the session's first timestamped entry. Only the
derived step's identity changes; the source line still rides verbatim
inside the step's structural change (`extra["raw"]`), so projecting back
to Claude JSONL is unaffected.
- **`path-cli`** (0.15.0): `path p validate` opts into format assertion,
so `format: date-time` on step timestamps and `format: uri` on
`meta.kind` / path-ref `$ref` / signature `href` are enforced instead of
ignored. Documents imported with an older `toolpath-claude` may now fail
validation on their empty preamble timestamps; re-importing the session
fixes them. (`toolpath-cli` 0.15.0 tracks the bump.)

## Derive: resolve duplicate step ids — 2026-07-01

- **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,14 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in
- `toolpath-convo`: 118 unit + 4 doc tests (types, enrichment, display, ConversationView -> Path derivation, message-group usage accounting, breakdowns)
- `toolpath-git`: 33 unit + 3 doc tests (derive, branch detection, diffstat)
- `toolpath-github`: 32 unit + 3 doc tests (mapping, DAG construction, fixtures)
- `toolpath-claude`: 229 unit + 18 integration + 6 doc tests (path resolution, conversation reading, query, chaining, watcher, derive, metadata first-user-message, group_id grouping + once-per-message usage totals)
- `toolpath-claude`: 232 unit + 19 integration + 6 doc tests (path resolution, conversation reading, query, chaining, watcher, derive, metadata first-user-message, group_id grouping + once-per-message usage totals, preamble timestamp fallback)
- `toolpath-gemini`: 161 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip)
- `toolpath-codex`: 80 unit + 51 integration + 2 doc tests (rollout parsing, provider assembly, patch-fidelity derive, real-session fixture, source→path fidelity invariants, JSON wire-level round-trip, per-turn token deltas from cumulative counters, reasoning breakdown)
- `toolpath-opencode`: 52 unit + 19 integration + 1 doc test (SQLite reader, JSON payload serde, provider assembly, snapshot-based derive, tool-input fallback for gitignored paths, reasoning breakdown)
- `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`)
- `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider)
- `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping)
- `path-cli`: 294 unit + 65 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh <url>` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too.
- `path-cli`: 295 unit + 66 integration tests (import/export/cache, track sessions, merge, validate — including JSON Schema format assertion, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh <url>` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too.
- `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary)

Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done`
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ license = "Apache-2.0"
toolpath = { version = "0.7.0", path = "crates/toolpath" }
toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" }
toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false }
toolpath-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" }
toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" }
Expand All @@ -35,7 +35,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" }
toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" }
toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" }
toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" }
path-cli = { version = "0.14.0", path = "crates/path-cli" }
path-cli = { version = "0.15.0", path = "crates/path-cli" }
pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" }

reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/path-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "path-cli"
version = "0.14.0"
version = "0.15.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
46 changes: 42 additions & 4 deletions crates/path-cli/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ fn validator() -> &'static Validator {
VALIDATOR.get_or_init(|| {
let schema: serde_json::Value = serde_json::from_str(SCHEMA_SOURCE)
.expect("toolpath.schema.json embedded in binary parses as JSON");
jsonschema::validator_for(&schema)
// Draft 2020-12 treats `format` as annotation-only by default, but
// the schema means what it says: `$defs/timestamp` is
// `format: date-time` and derived documents must satisfy it (a
// `""` step timestamp used to sail through here and then fail on
// format-asserting consumers). Opt in to format assertion.
jsonschema::options()
.should_validate_formats(true)
.build(&schema)
.expect("toolpath.schema.json embedded in binary is itself a valid JSON Schema")
})
}
Expand All @@ -54,9 +61,12 @@ fn kind_validators() -> &'static HashMap<&'static str, Validator> {
.map(|(uri, source)| {
let schema: serde_json::Value = serde_json::from_str(source)
.unwrap_or_else(|e| panic!("bundled kind schema {uri} is not valid JSON: {e}"));
let v = jsonschema::validator_for(&schema).unwrap_or_else(|e| {
panic!("bundled kind schema {uri} is not a valid JSON Schema: {e}")
});
let v = jsonschema::options()
.should_validate_formats(true)
.build(&schema)
.unwrap_or_else(|e| {
panic!("bundled kind schema {uri} is not a valid JSON Schema: {e}")
});
(*uri, v)
})
.collect()
Expand Down Expand Up @@ -207,6 +217,34 @@ mod tests {
validate(&doc).expect("base may carry both ref and branch");
}

/// `$defs/timestamp` is `format: date-time`; format assertion is
/// opted in, so an empty step timestamp must fail — surfaced as a
/// oneOf violation at the containing path (`$defs/pathOrRef`).
/// Regression: `toolpath-claude` used to stamp `""` on steps derived
/// from timestamp-less preamble lines (`last-prompt`, `ai-title`).
#[test]
fn empty_step_timestamp_is_rejected() {
let doc = json!({
"graph": {"id": "g1"},
"paths": [{
"path": {"id": "p1", "head": "s1"},
"steps": [{
"step": {
"id": "s1",
"actor": "human:alex",
"timestamp": ""
},
"change": {"src/main.rs": {"raw": "@@ -1 +1 @@\n-a\n+b"}}
}]
}]
});
let err = validate(&doc).expect_err("empty timestamp violates format: date-time");
assert!(
err.to_string().contains("/paths/0"),
"error should point at the offending path: {err}"
);
}

/// `path.base` is optional: a Graph that wraps a single step (the shape
/// of the new `step-NN.json` example fixtures) has no base, and that's
/// fine.
Expand Down
56 changes: 56 additions & 0 deletions crates/path-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,62 @@ fn derive_git_validate_roundtrip() {
let _ = std::fs::remove_file(&tmp_file);
}

/// Importing a session whose JSONL carries headerless preamble lines
/// without timestamps (`last-prompt`, `ai-title` — the shape a headless
/// `claude -p` run writes) must produce a schema-valid document. The
/// derived claude-preamble-* steps used to carry `"timestamp": ""`,
/// which `p validate` rejects (`$defs/timestamp` is `format: date-time`).
#[test]
fn import_claude_with_untimestamped_preamble_validates() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let claude_dir = temp.path().join(".claude");
let project_slug = project
.to_string_lossy()
.replace([std::path::MAIN_SEPARATOR, '_', '.'], "-");
let project_dir = claude_dir.join("projects").join(&project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join("session-pre.jsonl"),
format!(
r#"{{"type":"ai-title","aiTitle":"Say hi","sessionId":"session-pre"}}
{{"type":"queue-operation","operation":"enqueue","content":"queued","timestamp":"2024-01-01T00:00:03Z","sessionId":"session-pre"}}
{{"type":"last-prompt","lastPrompt":"hi","leafUuid":"u-1","sessionId":"session-pre"}}
{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}}
{{"type":"assistant","uuid":"a-1","parentUuid":"u-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}}
"#,
cwd = project.display()
),
)
.unwrap();

let import_output = cmd()
.env("HOME", temp.path())
.args(["p", "import", "claude", "--session", "session-pre", "--no-cache", "--project"])
.arg(&project)
.output()
.unwrap();
assert!(
import_output.status.success(),
"import failed: {}",
String::from_utf8_lossy(&import_output.stderr)
);

let tmp_file = std::env::temp_dir().join("toolpath-integration-claude-preamble.json");
std::fs::write(&tmp_file, &import_output.stdout).unwrap();

cmd()
.args(["p", "validate"])
.arg("--input")
.arg(&tmp_file)
.assert()
.success()
.stdout(predicate::str::contains("Valid"));

let _ = std::fs::remove_file(&tmp_file);
}

// ── Render ───────────────────────────────────────────────────────────

#[test]
Expand Down
3 changes: 2 additions & 1 deletion crates/toolpath-claude/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toolpath-claude"
version = "0.12.0"
version = "0.12.1"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand All @@ -25,5 +25,6 @@ notify = { workspace = true, optional = true }
tokio = { workspace = true, features = ["sync", "time", "fs"], optional = true }

[dev-dependencies]
chrono = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros"] }
44 changes: 44 additions & 0 deletions crates/toolpath-claude/src/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,48 @@ mod tests {
let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
}

#[test]
fn derive_path_preamble_steps_carry_valid_timestamps() {
// A headless (`claude -p`) session caches its prompt as a headerless
// `last-prompt` line with no `timestamp` on the wire. The derived
// step must still satisfy the schema's `format: date-time` —
// regression test for `"timestamp": ""` on claude-preamble-* steps
// failing `path p validate`.
let mut convo = make_convo();
convo.preamble = vec![
serde_json::json!({
"type": "queue-operation",
"operation": "enqueue",
"content": "queued while busy",
"timestamp": "2026-01-01T00:00:02Z",
"sessionId": "sess-1abc",
}),
serde_json::json!({
"type": "last-prompt",
"lastPrompt": "Fix bug",
"leafUuid": "u1",
"sessionId": "sess-1abc",
}),
];

let path = derive_path(&convo, &DeriveConfig::default());

let last_prompt = path
.steps
.iter()
.find(|s| s.step.id == "claude-preamble-1")
.expect("last-prompt preamble step");
// leafUuid resolves to the referenced user entry's timestamp.
assert_eq!(last_prompt.step.timestamp, "2026-01-01T00:00:00Z");

for step in &path.steps {
assert!(
chrono::DateTime::parse_from_rfc3339(&step.step.timestamp).is_ok(),
"step {} timestamp {:?} is not RFC 3339",
step.step.id,
step.step.timestamp
);
}
}
}
Loading
Loading