From 4c1ee8ccef8f2454350061e9d3fe24a1aa5284a0 Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Mon, 27 Apr 2026 16:44:30 -0400 Subject: [PATCH] feat!: simplify toolpath format to single Graph root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `Document` enum and the `{"Step":…}` / `{"Path":…}` / `{"Graph":…}` envelope. Every `.path.json` file is a `Graph` at the root; every `.path.jsonl` file is a single-path `Graph` at the file boundary. What was a bare Step or Path is now wrapped in a single-path Graph — one schema, one parser path, no envelope to detect. Why: the three-variant root pushed discriminator logic into every consumer (CLI commands, renderers, schema, fixtures, docs). Collapsing to a single root type makes file shape uniform and removes a class of "which kind is this?" branching across the workspace. Public API changes (pre-1.0 = potentially breaking): - toolpath 0.2.0 → 0.3.0 — Document enum removed; new helpers on Graph (`from_json`/`to_json`/`to_json_pretty`, `from_path`, `single_path`, `into_single_path`) plus JSONL on Graph (`Graph::from_jsonl_*` / `to_jsonl_*` wrap a single inline Path; new `JsonlError::NotSinglePathGraph`) - toolpath-git 0.1.3 → 0.2.0 — `derive` returns Graph - toolpath-dot 0.1.2 → 0.2.0 — `render` takes &Graph (single-path graphs use the path layout, multi-path use the cluster layout) - toolpath-md 0.2.0 → 0.3.0 — `render` takes &Graph (same dispatch) - toolpath-pi 0.2.0 → 0.3.0 — `derive_project` returns Graph - toolpath-cli 0.5.0 → 0.6.0 — file format change end-to-end CLI: `validate`, `render`, `query`, `merge`, `import`, `export`, `track`, `cache` all read/write Graph at file boundaries. `export claude` and `export gemini` reject multi-path graphs with a clear error. Examples: `step-NN.json` are now single-path single-step graphs; `path-NN.path.json` are single-path graphs; `graph-01-release.json` drops its envelope. JSONL fixtures regenerated. Schema: `schema/toolpath.schema.json` collapses the root `oneOf` of three envelopes into a direct `$ref` to the `graph` definition. Docs: RFC.md, docs/RFC-jsonl.md, README.md, CHANGELOG.md, and per-crate READMEs all updated. Tests: workspace fully green (cargo build, cargo test --workspace, cargo clippy --workspace -- -D warnings). Render-md snapshot tests for step examples regenerated under the new single-path-graph rendering. --- CHANGELOG.md | 59 ++ Cargo.lock | 508 ++++++++---------- Cargo.toml | 12 +- README.md | 7 +- RFC.md | 376 +++++++------ crates/path-cli/Cargo.toml | 2 +- .../examples/convert-path-to-jsonl.rs | 10 +- crates/path-cli/src/bin/gen_synthetic_path.rs | 4 +- crates/path-cli/src/cmd_cache.rs | 12 +- crates/path-cli/src/cmd_export.rs | 104 ++-- crates/path-cli/src/cmd_import.rs | 53 +- crates/path-cli/src/cmd_merge.rs | 171 ++---- crates/path-cli/src/cmd_query.rs | 65 +-- crates/path-cli/src/cmd_render.rs | 14 +- crates/path-cli/src/cmd_show.rs | 4 +- crates/path-cli/src/cmd_track.rs | 159 +++--- crates/path-cli/src/cmd_validate.rs | 38 +- crates/path-cli/src/io.rs | 20 +- crates/path-cli/tests/integration.rs | 25 +- crates/path-cli/tests/roundtrip.rs | 2 +- ..._snapshots__render_md_step_01_minimal.snap | 13 +- ...md_snapshots__render_md_step_02_agent.snap | 13 +- ...napshots__render_md_step_03_formatter.snap | 13 +- ...s__render_md_step_04_human_refinement.snap | 13 +- ...snapshots__render_md_step_05_dead_end.snap | 13 +- ...d_snapshots__render_md_step_06_signed.snap | 13 +- ...md_snapshots__render_md_step_07_merge.snap | 12 +- crates/toolpath-cli/Cargo.toml | 6 +- crates/toolpath-codex/src/derive.rs | 18 +- .../toolpath-codex/tests/fixture_roundtrip.rs | 16 +- crates/toolpath-dot/Cargo.toml | 2 +- crates/toolpath-dot/README.md | 12 +- crates/toolpath-dot/src/lib.rs | 73 ++- .../tests/fixture_roundtrip.rs | 20 +- .../tests/projection_roundtrip.rs | 15 +- crates/toolpath-git/Cargo.toml | 2 +- crates/toolpath-git/src/lib.rs | 67 +-- crates/toolpath-md/Cargo.toml | 2 +- crates/toolpath-md/README.md | 22 +- crates/toolpath-md/src/lib.rs | 52 +- crates/toolpath-opencode/src/derive.rs | 15 +- crates/toolpath-pi/Cargo.toml | 2 +- crates/toolpath-pi/src/derive.rs | 9 +- crates/toolpath-pi/tests/end_to_end.rs | 4 +- crates/toolpath/Cargo.toml | 2 +- crates/toolpath/README.md | 40 +- crates/toolpath/src/jsonl.rs | 101 +++- crates/toolpath/src/lib.rs | 9 +- crates/toolpath/src/types.rs | 197 ++++--- docs/RFC-jsonl.md | 38 +- examples/graph-01-release.json | 345 ++++++------ examples/path-01-pr.path.json | 184 ++++--- examples/path-02-local-session.path.json | 157 +++--- examples/path-03-signed-pr.path.json | 292 +++++----- examples/path-04-exploration.path.json | 281 +++++----- examples/path-04-exploration.path.jsonl | 6 +- examples/step-01-minimal.json | 36 +- examples/step-02-agent.json | 104 ++-- examples/step-03-formatter.json | 47 +- examples/step-04-human-refinement.json | 72 ++- examples/step-05-dead-end.json | 77 ++- examples/step-06-signed.json | 106 ++-- examples/step-07-merge.json | 48 +- schema/toolpath.schema.json | 33 +- site/_data/crates.json | 22 +- 65 files changed, 2217 insertions(+), 2052 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428c1e1..c82426f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ All notable changes to the Toolpath workspace are documented here. +## Format simplification — single-root Graph + +**Breaking.** `Graph` is now the only root document type. Every `.path.json` +file deserializes to a `Graph`; every `.path.jsonl` file is a single-path +`Graph` at the boundary. The previous three-variant `Document` enum +(`{"Step": …}` / `{"Path": …}` / `{"Graph": …}` envelopes) is removed. + +What was a single Step or single Path at the root is now wrapped in a +single-path Graph: `Graph { graph: { id }, paths: [Path { …, steps: [Step] }] }`. +This unifies file shape — one schema, one parser path, no envelope to detect. + +### toolpath 0.3.0 + +- **Remove `Document` enum.** The new root type is `Graph`. All `Graph::*` + helpers (`from_json`, `to_json`, `to_json_pretty`) plus `Graph::from_path`, + `Graph::single_path`, `Graph::into_single_path` cover the previous + `Document` surface and add ergonomic single-path lifts. +- **JSONL.** `Graph::from_jsonl_*` / `Graph::to_jsonl_*` are the file-level + API. They wrap a single inline `Path` as a single-path `Graph`. The + underlying line-streaming machinery on `Path` is unchanged. New + `JsonlError::NotSinglePathGraph` flags multi-path graphs at write time. + +### toolpath-git 0.2.0 + +- `derive` returns `Graph` (was `Document`). A single branch yields a + single-path graph; multiple branches yield a multi-path graph. + +### toolpath-dot 0.2.0 + +- `render(&Graph, …)` replaces `render(&Document, …)`. Single-path graphs + render through the existing path-level layout; multi-path graphs use the + cluster layout. `render_step`, `render_path`, `render_graph` remain. + +### toolpath-md 0.3.0 + +- `render(&Graph, …)` replaces `render(&Document, …)`. Same single-path / + multi-path dispatch. + +### toolpath-pi 0.3.0 + +- `derive_project` returns `Graph` (was `Document::Graph(...)`). + +### path-cli 0.6.0 + +- All commands (`validate`, `render`, `query`, `merge`, `import`, `export`, + `track`, `cache`) read and write `Graph` documents at file boundaries. +- `export claude` / `export gemini` reject multi-path graphs with a clear + error — projection requires exactly one inline path. +- `examples/` rewritten as graph-rooted JSON. `step-NN.json` examples are + now single-path single-step graphs; `path-NN.path.json` are single-path + graphs; `graph-01-release.json` drops its envelope. +- Schema (`schema/toolpath.schema.json`) collapses the root `oneOf` of + three envelopes into a direct `$ref` to the `graph` definition. + +### toolpath-cli 0.6.0 (deprecation shim) + +- Bumped in lockstep with `path-cli` 0.6.0 (its only dependency). No + behavioral change. + ## path-cli 0.5.0 + toolpath-cli 0.5.1 + workspace re-alignment ### path-cli 0.5.0 (new crate name) diff --git a/Cargo.lock b/Cargo.lock index 8835030..66009a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -49,15 +49,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -84,15 +84,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "assert_cmd" -version = "2.1.2" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" dependencies = [ "anstyle", "bstr", @@ -129,9 +129,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -155,9 +155,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" @@ -167,9 +167,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.55" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -185,9 +185,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.58" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -209,9 +209,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -221,9 +221,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -233,26 +233,25 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -372,9 +371,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -525,19 +524,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -548,7 +547,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "libc", "libgit2-sys", "log", @@ -596,9 +595,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" @@ -656,9 +655,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -670,7 +669,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -678,15 +676,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -759,12 +756,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -772,9 +770,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -785,9 +783,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -799,15 +797,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -819,15 +817,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -857,9 +855,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -867,12 +865,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -899,9 +897,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.46.3" +version = "1.47.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "console", "once_cell", @@ -926,9 +924,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -942,9 +940,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -958,10 +956,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -994,9 +994,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.181" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" @@ -1014,13 +1014,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "libc", - "redox_syscall 0.7.1", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -1050,9 +1051,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "libc", @@ -1062,15 +1063,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1101,9 +1102,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1140,7 +1141,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "filetime", "fsevent-sys", "inotify", @@ -1173,9 +1174,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1185,11 +1186,11 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -1223,9 +1224,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -1258,7 +1259,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "assert_cmd", @@ -1294,27 +1295,27 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1379,9 +1380,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1392,11 +1393,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -1427,16 +1434,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.7.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", ] [[package]] @@ -1530,7 +1537,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1540,11 +1547,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1553,9 +1560,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "rustls-pki-types", @@ -1566,18 +1573,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -1607,9 +1614,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -1622,11 +1629,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1645,9 +1652,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1762,12 +1769,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1790,9 +1797,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.115" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1825,7 +1832,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1842,12 +1849,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1881,9 +1888,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1891,9 +1898,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1908,9 +1915,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1952,7 +1959,7 @@ dependencies = [ [[package]] name = "toolpath" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -2002,7 +2009,7 @@ dependencies = [ [[package]] name = "toolpath-dot" -version = "0.1.3" +version = "0.2.0" dependencies = [ "toolpath", ] @@ -2024,7 +2031,7 @@ dependencies = [ [[package]] name = "toolpath-git" -version = "0.1.4" +version = "0.2.0" dependencies = [ "anyhow", "chrono", @@ -2047,7 +2054,7 @@ dependencies = [ [[package]] name = "toolpath-md" -version = "0.2.1" +version = "0.3.0" dependencies = [ "serde_json", "toolpath", @@ -2072,7 +2079,7 @@ dependencies = [ [[package]] name = "toolpath-pi" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", @@ -2105,7 +2112,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -2156,15 +2163,15 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-xid" @@ -2250,11 +2257,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -2263,14 +2270,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -2281,23 +2288,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2305,9 +2308,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -2318,9 +2321,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -2353,7 +2356,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2361,9 +2364,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -2454,25 +2457,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2490,31 +2475,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2523,96 +2491,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2622,6 +2542,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -2671,7 +2597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -2703,15 +2629,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2720,9 +2646,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2732,18 +2658,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2752,18 +2678,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2779,9 +2705,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2790,9 +2716,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2801,9 +2727,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 49a9722..3dddf1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,18 +21,18 @@ edition = "2024" license = "Apache-2.0" [workspace.dependencies] -toolpath = { version = "0.2.0", path = "crates/toolpath" } +toolpath = { version = "0.3.0", path = "crates/toolpath" } toolpath-convo = { version = "0.7.0", path = "crates/toolpath-convo" } -toolpath-git = { version = "0.1.4", path = "crates/toolpath-git" } +toolpath-git = { version = "0.2.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.8.0", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.2.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.1.0", path = "crates/toolpath-codex" } toolpath-opencode = { version = "0.1.0", path = "crates/toolpath-opencode" } toolpath-github = { version = "0.2.1", path = "crates/toolpath-github" } -toolpath-dot = { version = "0.1.3", path = "crates/toolpath-dot" } -toolpath-md = { version = "0.2.1", path = "crates/toolpath-md" } -toolpath-pi = { version = "0.2.0", path = "crates/toolpath-pi" } -path-cli = { version = "0.5.0", path = "crates/path-cli" } +toolpath-dot = { version = "0.2.0", path = "crates/toolpath-dot" } +toolpath-md = { version = "0.3.0", path = "crates/toolpath-md" } +toolpath-pi = { version = "0.3.0", path = "crates/toolpath-pi" } +path-cli = { version = "0.6.0", path = "crates/path-cli" } reqwest = { version = "0.12", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index 56f2171..a62758c 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ path list codex --format tsv \ ### Core types ```rust -use toolpath::{Step, Path, Base, Document}; +use toolpath::{Step, Path, Base, Graph}; let step = Step::new("step-001", "human:alex", "2026-01-29T10:00:00Z") .with_parent("step-000") @@ -232,6 +232,11 @@ let path = Path::new( Some(Base::vcs("github:org/repo", "abc123")), "step-001", ); + +// Graph is the single root type of every Toolpath document. Wrap a single +// Path as a one-path Graph for serialization: +let graph = Graph::from_path(path); +let json = graph.to_json_pretty()?; ``` ### Query operations diff --git a/RFC.md b/RFC.md index e9948db..c628095 100644 --- a/RFC.md +++ b/RFC.md @@ -87,21 +87,23 @@ Toolpath defines three object types at decreasing levels of granularity: Each level can exist standalone or be nested. Steps can stream independently, paths can represent complete PRs, and graphs can bundle related paths. -### Document Envelope +### Document Root -A Toolpath document is **externally tagged**: the top-level JSON object has -exactly one key — `"Step"`, `"Path"`, or `"Graph"` — whose value contains the -document content. This makes the document type unambiguous without inspecting -the inner fields. +Every Toolpath document is a **Graph** at the JSON root — there is no envelope +or discriminator key. The single root type keeps both file shape and tooling +uniform: ```json -{ "Step": { "step": {...}, "change": {...} } } -{ "Path": { "path": {...}, "steps": [...] } } -{ "Graph": { "graph": {...}, "paths": [...] } } +{ "graph": {...}, "paths": [...] } ``` -PascalCase variant names visually distinguish the type tag from the lowercase -structural fields inside (`step`, `path`, `graph`). +A single PR or coding session is a Graph that holds one inline path in +`paths`; a multi-PR release or a bundle of related agent sessions is a Graph +that holds several. Each `paths[*]` entry is either an inline `Path` object or +a `$ref` to an external path document. + +`Step` and `Path` are **inner** types — they appear inside `Graph.paths` and +`Path.steps`. They are not valid document roots. ### File Extensions @@ -110,12 +112,12 @@ serialization format: | Extension | Document type | Description | | --------- | ------------- | ----------- | -| `.path.json` | Path (canonical) | A complete `{"Path": {...}}` JSON document. | -| `.path.jsonl` | Path (streaming) | A line-oriented JSONL stream that seals to a `Path`. See the [JSONL Streaming RFC](docs/RFC-jsonl.md). | +| `.path.json` | Graph (canonical) | A serialized `Graph` JSON document. | +| `.path.jsonl` | Graph (streaming) | A line-oriented JSONL stream that seals to a single-path `Graph`. See the [JSONL Streaming RFC](docs/RFC-jsonl.md). | -`Step` and `Graph` documents use plain `.json` files with the appropriate -`{"Step": ...}` or `{"Graph": ...}` envelope. Only `Path` has a streaming -peer format. +The `.path.jsonl` form encodes exactly one inline `Path` and is wrapped as a +single-path `Graph` at the file boundary. Multi-path graphs and `$ref`-only +entries cannot be represented in JSONL — those require canonical `.path.json`. Graph `$ref` entries MUST point to sealed `.path.json` files, not to `.path.jsonl` streams. @@ -397,39 +399,39 @@ indicates which step is the current tip. ### Step Object +A `Step` is an inner type that lives inside `Path.steps`. The JSON shape: + ```json { - "Step": { - "step": { - "id": "step-003", - "parents": ["step-002"], - "actor": "agent:claude-code", - "timestamp": "2026-01-29T15:30:00Z" - }, + "step": { + "id": "step-003", + "parents": ["step-002"], + "actor": "agent:claude-code", + "timestamp": "2026-01-29T15:30:00Z" + }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,25 @@\n use std::error::Error;\n+...", - "structural": { - "type": "rust.add_items", - "items": [ - {"kind": "struct", "name": "ValidationError"}, - {"kind": "fn", "name": "validate_email"} - ] - } - }, - "src/auth/mod.rs": { - "raw": "@@ -1,1 +1,2 @@\n+pub mod validator;" + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,25 @@\n use std::error::Error;\n+...", + "structural": { + "type": "rust.add_items", + "items": [ + {"kind": "struct", "name": "ValidationError"}, + {"kind": "fn", "name": "validate_email"} + ] } }, - - "meta": { - "intent": "Add email validation to prevent malformed input", - "refs": [ - {"rel": "fixes", "href": "issue://github/repo/issues/42"}, - {"rel": "implements", "href": "doc://design/input-validation.md"} - ] + "src/auth/mod.rs": { + "raw": "@@ -1,1 +1,2 @@\n+pub mod validator;" } + }, + + "meta": { + "intent": "Add email validation to prevent malformed input", + "refs": [ + {"rel": "fixes", "href": "issue://github/repo/issues/42"}, + {"rel": "implements", "href": "doc://design/input-validation.md"} + ] } } ``` @@ -440,17 +442,15 @@ A step can be minimal: ```json { - "Step": { - "step": { - "id": "step-001", - "actor": "human:alex", - "timestamp": "2026-01-29T10:00:00Z" - }, + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-01-29T10:00:00Z" + }, - "change": { - "src/main.rs": { - "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" - } + "change": { + "src/main.rs": { + "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" } } } @@ -464,103 +464,99 @@ A step with full actor and signature metadata: ```json { - "Step": { - "step": { - "id": "step-001", - "actor": "human:alex", - "timestamp": "2026-01-29T10:00:00Z" - }, + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-01-29T10:00:00Z" + }, + + "change": { + "src/main.rs": { + "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" + } + }, - "change": { - "src/main.rs": { - "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" + "meta": { + "intent": "Fix greeting punctuation", + "actors": { + "human:alex": { + "name": "Alex Kesling", + "identities": [{"system": "github", "id": "akesling"}], + "keys": [{"type": "gpg", "fingerprint": "ABCD1234..."}] } }, - - "meta": { - "intent": "Fix greeting punctuation", - "actors": { - "human:alex": { - "name": "Alex Kesling", - "identities": [{"system": "github", "id": "akesling"}], - "keys": [{"type": "gpg", "fingerprint": "ABCD1234..."}] - } - }, - "signatures": [ - { - "signer": "human:alex", - "key": "gpg:ABCD1234", - "scope": "author", - "sig": "-----BEGIN PGP SIGNATURE-----\n..." - } - ] - } + "signatures": [ + { + "signer": "human:alex", + "key": "gpg:ABCD1234", + "scope": "author", + "sig": "-----BEGIN PGP SIGNATURE-----\n..." + } + ] } } ``` ### Path Object -A path collects steps and provides context: +A path collects steps and provides context. Paths live inside `Graph.paths`: ```json { - "Path": { - "path": { - "id": "path-pr-42", - "base": { - "uri": "github:myorg/myrepo", - "ref": "abc123def456" - }, - "head": "step-004" + "path": { + "id": "path-pr-42", + "base": { + "uri": "github:myorg/myrepo", + "ref": "abc123def456" + }, + "head": "step-004" + }, + + "steps": [ + { + "step": { "id": "step-001", "actor": "agent:claude-code", "timestamp": "..." }, + "change": { "src/validator.rs": { "raw": "..." } }, + "meta": { "intent": "Add validation struct" } + }, + { + "step": { "id": "step-002", "parents": ["step-001"], "actor": "tool:rustfmt", "timestamp": "..." }, + "change": { "src/validator.rs": { "raw": "..." } }, + "meta": { "intent": "Auto-format" } + }, + { + "step": { "id": "step-001a", "parents": ["step-001"], "actor": "agent:claude-code", "timestamp": "..." }, + "change": { "src/validator.rs": { "raw": "..." } }, + "meta": { "intent": "Regex approach (abandoned)" } }, + { + "step": { "id": "step-003", "parents": ["step-002"], "actor": "human:alex", "timestamp": "..." }, + "change": { "src/validator.rs": { "raw": "..." } }, + "meta": { "intent": "Refine error messages" } + } + ], - "steps": [ - { - "step": { "id": "step-001", "actor": "agent:claude-code", "timestamp": "..." }, - "change": { "src/validator.rs": { "raw": "..." } }, - "meta": { "intent": "Add validation struct" } - }, - { - "step": { "id": "step-002", "parents": ["step-001"], "actor": "tool:rustfmt", "timestamp": "..." }, - "change": { "src/validator.rs": { "raw": "..." } }, - "meta": { "intent": "Auto-format" } + "meta": { + "title": "Add email validation", + "source": "github:myorg/myrepo/pull/42", + "actors": { + "human:alex": { + "name": "Alex Kesling", + "identities": [{"system": "github", "id": "akesling"}], + "keys": [{"type": "gpg", "fingerprint": "ABCD1234..."}] }, - { - "step": { "id": "step-001a", "parents": ["step-001"], "actor": "agent:claude-code", "timestamp": "..." }, - "change": { "src/validator.rs": { "raw": "..." } }, - "meta": { "intent": "Regex approach (abandoned)" } + "agent:claude-code": { + "name": "Claude Code", + "provider": "anthropic" }, - { - "step": { "id": "step-003", "parents": ["step-002"], "actor": "human:alex", "timestamp": "..." }, - "change": { "src/validator.rs": { "raw": "..." } }, - "meta": { "intent": "Refine error messages" } + "tool:rustfmt": { + "name": "rustfmt", + "identities": [{"system": "crates.io", "id": "rustfmt-nightly/1.7.0"}] } - ], - - "meta": { - "title": "Add email validation", - "source": "github:myorg/myrepo/pull/42", - "actors": { - "human:alex": { - "name": "Alex Kesling", - "identities": [{"system": "github", "id": "akesling"}], - "keys": [{"type": "gpg", "fingerprint": "ABCD1234..."}] - }, - "agent:claude-code": { - "name": "Claude Code", - "provider": "anthropic" - }, - "tool:rustfmt": { - "name": "rustfmt", - "identities": [{"system": "crates.io", "id": "rustfmt-nightly/1.7.0"}] - } - }, - "signatures": [ - {"signer": "human:alex", "key": "gpg:ABCD1234", "scope": "author", "sig": "..."}, - {"signer": "human:bob", "key": "gpg:EFGH5678", "scope": "reviewer", "sig": "..."} - ] - } + }, + "signatures": [ + {"signer": "human:alex", "key": "gpg:ABCD1234", "scope": "author", "sig": "..."}, + {"signer": "human:bob", "key": "gpg:EFGH5678", "scope": "reviewer", "sig": "..."} + ] } } ``` @@ -632,40 +628,38 @@ occur between VCS commits. ### Graph Object -A graph collects related paths: +A graph collects related paths and is the root document type: ```json { - "Graph": { - "graph": { - "id": "graph-release-v2" + "graph": { + "id": "graph-release-v2" + }, + + "paths": [ + { + "path": { "id": "path-pr-42", "base": {...}, "head": "step-004" }, + "steps": [...], + "meta": { "title": "Add email validation" } }, + { + "path": { "id": "path-pr-43", "base": {...}, "head": "step-003" }, + "steps": [...], + "meta": { "title": "Fix authentication bug" } + }, + { "$ref": "https://archive.example.com/toolpath/path-pr-44.json" }, + { "$ref": "toolpath://internal/path-pr-45" } + ], - "paths": [ - { - "path": { "id": "path-pr-42", "base": {...}, "head": "step-004" }, - "steps": [...], - "meta": { "title": "Add email validation" } - }, - { - "path": { "id": "path-pr-43", "base": {...}, "head": "step-003" }, - "steps": [...], - "meta": { "title": "Fix authentication bug" } - }, - { "$ref": "https://archive.example.com/toolpath/path-pr-44.json" }, - { "$ref": "toolpath://internal/path-pr-45" } + "meta": { + "title": "Release v2.0", + "refs": [ + {"rel": "milestone", "href": "issue://github/myorg/myrepo/milestone/5"} ], - - "meta": { - "title": "Release v2.0", - "refs": [ - {"rel": "milestone", "href": "issue://github/myorg/myrepo/milestone/5"} - ], - "actors": {...}, - "signatures": [ - {"signer": "human:release-manager", "key": "gpg:...", "scope": "release", "sig": "..."} - ] - } + "actors": {...}, + "signatures": [ + {"signer": "human:release-manager", "key": "gpg:...", "scope": "release", "sig": "..."} + ] } } ``` @@ -701,8 +695,7 @@ To produce a signable byte sequence: #### Step Signature (Author) -Signs the inner `step` + `change` fields (excluding both `meta` and the -`"Step"` document wrapper): +Signs the inner `step` + `change` fields (excluding `meta`): ``` canonical_input = JCS({ @@ -751,27 +744,26 @@ This attests: "I reviewed this path at this head and approve it." ### Canonicalization Example -Input document: +Input step (as it appears inside `Path.steps`): ```json { - "Step": { - "step": { - "timestamp": "2026-01-29T10:00:00Z", - "id": "step-001", - "actor": "human:alex" - }, - "change": { - "src/main.rs": { - "raw": "@@ -1,1 +1,1 @@\n-hello\n+world" - } - }, - "meta": { "intent": "Fix greeting" } - } + "step": { + "timestamp": "2026-01-29T10:00:00Z", + "id": "step-001", + "actor": "human:alex" + }, + "change": { + "src/main.rs": { + "raw": "@@ -1,1 +1,1 @@\n-hello\n+world" + } + }, + "meta": { "intent": "Fix greeting" } } ``` -Signing operates on the **inner** step content (without the `"Step"` wrapper). +Signing operates on the `step` + `change` pair (the `meta` block is excluded +because it carries the signatures themselves). Canonical form (for signing): @@ -814,22 +806,25 @@ verify_path_signatures(path_document, required_scopes): ### 1. Representing a PR -A PR is a path rooted at the target branch: +A PR is a single-path graph rooted at the target branch: ```json { - "Path": { - "path": { - "id": "pr-123", - "base": { "uri": "github:myorg/myrepo", "ref": "abc123" }, - "head": "step-final" - }, - "steps": [...], - "meta": { - "title": "Add email validation", - "source": "github:myorg/myrepo/pull/123" + "graph": { "id": "graph-pr-123" }, + "paths": [ + { + "path": { + "id": "pr-123", + "base": { "uri": "github:myorg/myrepo", "ref": "abc123" }, + "head": "step-final" + }, + "steps": [...], + "meta": { + "title": "Add email validation", + "source": "github:myorg/myrepo/pull/123" + } } - } + ] } ``` @@ -935,8 +930,7 @@ Toolpath doesn't replace your VCS—it layers richer provenance on top. A JSON Schema for validating Toolpath documents is available at [schema/toolpath.schema.json](./schema/toolpath.schema.json). -The schema validates all three externally tagged document types (`Step`, `Path`, -`Graph`) and enforces: +The schema validates `Graph` documents (the single root type) and enforces: - Required fields and structure - Actor reference format (`type:name`) - Timestamp format (ISO 8601) diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 2db4db5..5468f28 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/examples/convert-path-to-jsonl.rs b/crates/path-cli/examples/convert-path-to-jsonl.rs index 396520a..4dcf171 100644 --- a/crates/path-cli/examples/convert-path-to-jsonl.rs +++ b/crates/path-cli/examples/convert-path-to-jsonl.rs @@ -5,7 +5,7 @@ //! Usage: `cargo run -p path-cli --example convert-path-to-jsonl -- ` use std::fs; -use toolpath::v1::{Document, Path}; +use toolpath::v1::Graph; fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); @@ -13,12 +13,8 @@ fn main() -> anyhow::Result<()> { let output = args.next().expect("usage: "); let json = fs::read_to_string(&input)?; - let doc = Document::from_json(&json)?; - let path: Path = match doc { - Document::Path(p) => p, - _ => anyhow::bail!("{input}: not a Path document"), - }; - let jsonl = path.to_jsonl_string()?; + let graph = Graph::from_json(&json)?; + let jsonl = graph.to_jsonl_string()?; fs::write(&output, jsonl)?; println!("wrote {output}"); Ok(()) diff --git a/crates/path-cli/src/bin/gen_synthetic_path.rs b/crates/path-cli/src/bin/gen_synthetic_path.rs index 9f4ff07..2699b51 100644 --- a/crates/path-cli/src/bin/gen_synthetic_path.rs +++ b/crates/path-cli/src/bin/gen_synthetic_path.rs @@ -28,7 +28,7 @@ use clap::Parser; use rand::{Rng, SeedableRng, rngs::StdRng}; use serde_json::{Value, json}; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Document, Path, PathIdentity, PathMeta, Step, StepMeta, + ActorDefinition, ArtifactChange, Base, Graph, Path, PathIdentity, PathMeta, Step, StepMeta, StructuralChange, }; @@ -311,7 +311,7 @@ fn main() -> Result<()> { }), }; - let doc = Document::Path(path); + let doc = Graph::from_path(path); let json = doc.to_json()?; if let Some(parent) = args.out.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index c80715e..e6a08c2 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, anyhow, bail}; use clap::Subcommand; use std::path::PathBuf; -use toolpath::v1::Document; +use toolpath::v1::Graph; use crate::config::config_dir; @@ -78,7 +78,7 @@ pub(crate) fn cache_path(id: &str) -> Result { /// Uses `O_CREAT | O_EXCL` (`create_new`) when `force == false` so the /// exists-check and the write are atomic — two concurrent `path import` /// invocations racing the same id can't silently stomp each other. -pub(crate) fn write_cached(id: &str, doc: &Document, force: bool) -> Result { +pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result { use std::io::Write; let dir = cache_dir()?; @@ -220,12 +220,8 @@ mod tests { result } - fn sample_doc() -> Document { - Document::Step(toolpath::v1::Step::new( - "s1", - "human:alex", - "2026-01-01T00:00:00Z", - )) + fn sample_doc() -> Graph { + Graph::new("g-sample") } #[test] diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 6e7fa99..705fbc3 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -126,17 +126,13 @@ fn load_path_doc(input: &str) -> Result { let file = cache_ref(input)?; let json = std::fs::read_to_string(&file) .with_context(|| format!("Failed to read {}", file.display()))?; - let doc: toolpath::v1::Document = serde_json::from_str(&json) + let doc = toolpath::v1::Graph::from_json(&json) .map_err(|e| anyhow::anyhow!("Failed to parse toolpath document: {}", e))?; - match doc { - toolpath::v1::Document::Path(p) => Ok(p), - toolpath::v1::Document::Step(_) => { - anyhow::bail!("Expected a Path document, got a Step") - } - toolpath::v1::Document::Graph(_) => { - anyhow::bail!("Expected a Path document, got a Graph") - } - } + doc.into_single_path().ok_or_else(|| { + anyhow::anyhow!( + "expected a single-path graph; the source graph holds zero or multiple paths" + ) + }) } #[cfg(not(target_os = "emscripten"))] @@ -427,7 +423,7 @@ fn run_pathbase(input: String, url_flag: Option) -> Result<()> { .with_context(|| format!("Failed to read {}", file.display()))?; // Validate locally so we give a clean error rather than relying on // the server to reject malformed payloads. - toolpath::v1::Document::from_json(&body) + toolpath::v1::Graph::from_json(&body) .map_err(|e| anyhow::anyhow!("Invalid toolpath document: {}", e))?; let session = require_session()?; @@ -476,7 +472,7 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, Step, StepIdentity, StructuralChange}; - fn make_path_doc() -> toolpath::v1::Document { + fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; let init_step = Step { @@ -541,7 +537,7 @@ mod tests { meta: None, }; - toolpath::v1::Document::Path(path) + toolpath::v1::Graph::from_path(path) } #[test] @@ -568,24 +564,40 @@ mod tests { } #[test] - fn claude_rejects_non_path_doc() { + fn claude_rejects_multi_path_graph() { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); - let step = Step { - step: StepIdentity { - id: "s1".into(), - parents: vec![], - actor: "human:x".into(), - timestamp: "2024-01-01T00:00:00Z".into(), + let make_path = |id: &str| toolpath::v1::Path { + path: PathIdentity { + id: id.into(), + base: None, + head: "s1".into(), + graph_ref: None, }, - change: HashMap::new(), + steps: vec![Step { + step: StepIdentity { + id: "s1".into(), + parents: vec![], + actor: "human:x".into(), + timestamp: "2024-01-01T00:00:00Z".into(), + }, + change: HashMap::new(), + meta: None, + }], meta: None, }; - let doc = toolpath::v1::Document::Step(step); - std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap(); + let multi = toolpath::v1::Graph { + graph: toolpath::v1::GraphIdentity { id: "g".into() }, + paths: vec![ + toolpath::v1::PathOrRef::Path(Box::new(make_path("p1"))), + toolpath::v1::PathOrRef::Path(Box::new(make_path("p2"))), + ], + meta: None, + }; + std::fs::write(&input_path, serde_json::to_string(&multi).unwrap()).unwrap(); let err = run_claude(input_path.to_string_lossy().to_string(), None, None).unwrap_err(); - assert!(err.to_string().contains("Step")); + assert!(err.to_string().contains("single-path graph")); } #[test] @@ -659,7 +671,7 @@ mod tests { }, meta: None, }; - let doc = toolpath::v1::Document::Path(toolpath::v1::Path { + let doc = toolpath::v1::Graph::from_path(toolpath::v1::Path { path: PathIdentity { id: "test-path".into(), base: None, @@ -726,21 +738,37 @@ mod tests { } #[test] - fn gemini_rejects_non_path_doc() { + fn gemini_rejects_multi_path_graph() { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); - let step = Step { - step: StepIdentity { - id: "s1".into(), - parents: vec![], - actor: "human:x".into(), - timestamp: "2024-01-01T00:00:00Z".into(), + let make_path = |id: &str| toolpath::v1::Path { + path: PathIdentity { + id: id.into(), + base: None, + head: "s1".into(), + graph_ref: None, }, - change: HashMap::new(), + steps: vec![Step { + step: StepIdentity { + id: "s1".into(), + parents: vec![], + actor: "human:x".into(), + timestamp: "2024-01-01T00:00:00Z".into(), + }, + change: HashMap::new(), + meta: None, + }], meta: None, }; - let doc = toolpath::v1::Document::Step(step); - std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap(); + let multi = toolpath::v1::Graph { + graph: toolpath::v1::GraphIdentity { id: "g".into() }, + paths: vec![ + toolpath::v1::PathOrRef::Path(Box::new(make_path("p1"))), + toolpath::v1::PathOrRef::Path(Box::new(make_path("p2"))), + ], + meta: None, + }; + std::fs::write(&input_path, serde_json::to_string(&multi).unwrap()).unwrap(); let project = temp.path().join("proj"); std::fs::create_dir_all(&project).unwrap(); @@ -749,8 +777,8 @@ mod tests { Some(project), None, ) - .expect_err("should reject Step doc"); - assert!(err.to_string().contains("Step")); + .expect_err("should reject multi-path graph"); + assert!(err.to_string().contains("single-path graph")); } #[test] @@ -792,7 +820,7 @@ mod tests { }, meta: None, }; - let doc = toolpath::v1::Document::Path(toolpath::v1::Path { + let doc = toolpath::v1::Graph::from_path(toolpath::v1::Path { path: PathIdentity { id: "test-path".into(), base: None, diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 91bdaf2..5cfefe2 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -12,7 +12,7 @@ use anyhow::Context; use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; -use toolpath::v1::Document; +use toolpath::v1::Graph; use crate::cmd_cache::{make_id, write_cached}; @@ -173,7 +173,7 @@ pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { struct DerivedDoc { cache_id: String, - doc: Document, + doc: Graph, } fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { @@ -198,11 +198,15 @@ fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Resul Ok(()) } -fn doc_summary(doc: &Document) -> String { - match doc { - Document::Graph(g) => format!("graph {} ({} paths)", g.graph.id, g.paths.len()), - Document::Path(p) => format!("path {} ({} steps)", p.path.id, p.steps.len()), - Document::Step(s) => format!("step {}", s.step.id), +fn doc_summary(doc: &Graph) -> String { + if let Some(p) = doc.single_path() { + format!( + "graph {} (1 path, {} steps)", + doc.graph.id, + p.steps.len() + ) + } else { + format!("graph {} ({} paths)", doc.graph.id, doc.paths.len()) } } @@ -304,14 +308,9 @@ fn short_path_hash(s: &str) -> String { format!("{:08x}", h.finish() as u32) } -/// Extract the inner identifier from a document (Path.path.id, Graph.graph.id, etc.) -/// without source prefix. -fn doc_inner_id(doc: &Document) -> String { - match doc { - Document::Graph(g) => g.graph.id.clone(), - Document::Path(p) => p.path.id.clone(), - Document::Step(s) => s.step.id.clone(), - } +/// Extract the inner identifier from a graph (without source prefix). +fn doc_inner_id(doc: &Graph) -> String { + doc.graph.id.clone() } fn derive_github( @@ -356,7 +355,7 @@ fn derive_github( }; let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?; - let doc = Document::Path(path); + let doc = Graph::from_path(path); let cache_id = make_id("github", &format!("{owner}_{repo_name}-{pr_number}")); Ok(vec![DerivedDoc { cache_id, doc }]) } @@ -459,7 +458,7 @@ fn wrap_paths_claude(paths: Vec) -> Result> let cache_id = make_id("claude", &p.path.id); DerivedDoc { cache_id, - doc: Document::Path(p), + doc: Graph::from_path(p), } }) .collect()) @@ -649,7 +648,7 @@ fn wrap_paths_gemini(paths: Vec) -> Result> let cache_id = make_id("gemini", &p.path.id); DerivedDoc { cache_id, - doc: Document::Path(p), + doc: Graph::from_path(p), } }) .collect()) @@ -803,7 +802,7 @@ fn wrap_paths_codex(paths: Vec) -> Result> { let cache_id = make_id("codex", &p.path.id); DerivedDoc { cache_id, - doc: Document::Path(p), + doc: Graph::from_path(p), } }) .collect()) @@ -931,7 +930,7 @@ fn wrap_paths_opencode(paths: Vec) -> Result let cache_id = make_id("opencode", &p.path.id); DerivedDoc { cache_id, - doc: Document::Path(p), + doc: Graph::from_path(p), } }) .collect()) @@ -1014,8 +1013,7 @@ fn derive_pi_with_manager( if sessions.is_empty() { anyhow::bail!("No Pi sessions found for project: {}", p); } - let graph = toolpath_pi::derive::derive_graph(&sessions, None, &config); - let doc = Document::Graph(graph); + let doc = toolpath_pi::derive::derive_graph(&sessions, None, &config); let cache_id = make_id("pi", &doc_inner_id(&doc)); return Ok(vec![DerivedDoc { cache_id, doc }]); } @@ -1031,7 +1029,9 @@ fn derive_pi_with_manager( .ok_or_else(|| { anyhow::anyhow!("No Pi sessions found for project: {}", p) })?; - let doc = Document::Path(toolpath_pi::derive::derive_path(&session, &config)); + let doc = Graph::from_path(toolpath_pi::derive::derive_path( + &session, &config, + )); let cache_id = make_id("pi", &doc_inner_id(&doc)); return Ok(vec![DerivedDoc { cache_id, doc }]); } @@ -1042,7 +1042,8 @@ fn derive_pi_with_manager( .most_recent_session(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; - let doc = Document::Path(toolpath_pi::derive::derive_path(&session, &config)); + let doc = + Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id("pi", &doc_inner_id(&doc)); return Ok(vec![DerivedDoc { cache_id, doc }]); } @@ -1070,7 +1071,7 @@ fn derive_pi_with_manager( let session = manager .read_session(project_path, session_id) .map_err(|e| anyhow::anyhow!("{}", e))?; - let doc = Document::Path(toolpath_pi::derive::derive_path(&session, &config)); + let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id("pi", &doc_inner_id(&doc)); docs.push(DerivedDoc { cache_id, doc }); } @@ -1252,7 +1253,7 @@ fn derive_pathbase(target: String, url_flag: Option) -> Result, title: Option, pretty: bool) -> Result<()> { let mut all_paths = Vec::new(); @@ -15,12 +15,12 @@ pub fn run(inputs: Vec, title: Option, pretty: bool) -> Result<( std::io::stdin() .read_to_string(&mut buf) .context("Failed to read from stdin")?; - Document::from_json(&buf).with_context(|| format!("Failed to parse {:?}", input))? + Graph::from_json(&buf).with_context(|| format!("Failed to parse {:?}", input))? } else { crate::io::read_document_auto(std::path::Path::new(input))? }; - extract_paths(doc, &mut all_paths); + all_paths.extend(doc.paths); } let doc = merge_into_graph(all_paths, title); @@ -35,45 +35,18 @@ pub fn run(inputs: Vec, title: Option, pretty: bool) -> Result<( Ok(()) } -/// Extract paths from a document and append them to the collector. -fn extract_paths(doc: Document, paths: &mut Vec) { - match doc { - Document::Graph(g) => { - paths.extend(g.paths); - } - Document::Path(p) => { - paths.push(PathOrRef::Path(Box::new(p))); - } - Document::Step(s) => { - // Wrap a bare step in a minimal path - let step_id = s.step.id.clone(); - let path = toolpath::v1::Path { - path: toolpath::v1::PathIdentity { - id: format!("path-{}", step_id), - base: None, - head: step_id, - graph_ref: None, - }, - steps: vec![s], - meta: None, - }; - paths.push(PathOrRef::Path(Box::new(path))); - } - } -} - -/// Merge collected paths into a Graph document. -fn merge_into_graph(paths: Vec, title: Option) -> Document { +/// Merge collected paths into a single Graph document. +fn merge_into_graph(paths: Vec, title: Option) -> Graph { let graph_id = format!("graph-merged-{}", paths.len()); - Document::Graph(Graph { + Graph { graph: GraphIdentity { id: graph_id }, paths, meta: title.map(|t| GraphMeta { title: Some(t), ..Default::default() }), - }) + } } #[cfg(test)] @@ -103,87 +76,14 @@ mod tests { } } - #[test] - fn test_extract_paths_from_path_doc() { - let path = make_path("p1", vec![make_step("s1", "human:alex")]); - let doc = Document::Path(path); - let mut paths = Vec::new(); - extract_paths(doc, &mut paths); - assert_eq!(paths.len(), 1); - if let PathOrRef::Path(p) = &paths[0] { - assert_eq!(p.path.id, "p1"); - } else { - panic!("Expected Path, got Ref"); - } - } - - #[test] - fn test_extract_paths_from_graph_doc() { - let p1 = make_path("p1", vec![make_step("s1", "human:alex")]); - let p2 = make_path("p2", vec![make_step("s2", "agent:claude")]); - let graph = Graph { - graph: GraphIdentity { - id: "g1".to_string(), - }, - paths: vec![PathOrRef::Path(Box::new(p1)), PathOrRef::Path(Box::new(p2))], - meta: None, - }; - let doc = Document::Graph(graph); - let mut paths = Vec::new(); - extract_paths(doc, &mut paths); - assert_eq!(paths.len(), 2); - } - - #[test] - fn test_extract_paths_from_step_doc() { - let step = make_step("s1", "human:alex"); - let doc = Document::Step(step); - let mut paths = Vec::new(); - extract_paths(doc, &mut paths); - assert_eq!(paths.len(), 1); - if let PathOrRef::Path(p) = &paths[0] { - assert_eq!(p.path.id, "path-s1"); - assert_eq!(p.steps.len(), 1); - assert_eq!(p.path.head, "s1"); - } else { - panic!("Expected Path, got Ref"); - } - } - - #[test] - fn test_extract_paths_from_graph_with_refs() { - let p1 = make_path("p1", vec![make_step("s1", "human:alex")]); - let graph = Graph { - graph: GraphIdentity { - id: "g1".to_string(), - }, - paths: vec![ - PathOrRef::Path(Box::new(p1)), - PathOrRef::Ref(PathRef { - ref_url: "https://example.com/path.json".to_string(), - }), - ], - meta: None, - }; - let doc = Document::Graph(graph); - let mut paths = Vec::new(); - extract_paths(doc, &mut paths); - assert_eq!(paths.len(), 2); - assert!(matches!(&paths[1], PathOrRef::Ref(_))); - } - #[test] fn test_merge_into_graph_no_title() { let p1 = make_path("p1", vec![make_step("s1", "human:alex")]); let paths = vec![PathOrRef::Path(Box::new(p1))]; let doc = merge_into_graph(paths, None); - if let Document::Graph(g) = doc { - assert_eq!(g.graph.id, "graph-merged-1"); - assert_eq!(g.paths.len(), 1); - assert!(g.meta.is_none()); - } else { - panic!("Expected Graph"); - } + assert_eq!(doc.graph.id, "graph-merged-1"); + assert_eq!(doc.paths.len(), 1); + assert!(doc.meta.is_none()); } #[test] @@ -192,24 +92,30 @@ mod tests { let p2 = make_path("p2", vec![make_step("s2", "agent:claude")]); let paths = vec![PathOrRef::Path(Box::new(p1)), PathOrRef::Path(Box::new(p2))]; let doc = merge_into_graph(paths, Some("My Graph".to_string())); - if let Document::Graph(g) = doc { - assert_eq!(g.graph.id, "graph-merged-2"); - assert_eq!(g.paths.len(), 2); - assert_eq!(g.meta.unwrap().title.unwrap(), "My Graph"); - } else { - panic!("Expected Graph"); - } + assert_eq!(doc.graph.id, "graph-merged-2"); + assert_eq!(doc.paths.len(), 2); + assert_eq!(doc.meta.unwrap().title.unwrap(), "My Graph"); + } + + #[test] + fn test_merge_into_graph_preserves_refs() { + let p1 = make_path("p1", vec![make_step("s1", "human:alex")]); + let paths = vec![ + PathOrRef::Path(Box::new(p1)), + PathOrRef::Ref(PathRef { + ref_url: "https://example.com/path.json".to_string(), + }), + ]; + let doc = merge_into_graph(paths, None); + assert_eq!(doc.paths.len(), 2); + assert!(matches!(&doc.paths[1], PathOrRef::Ref(_))); } #[test] fn test_merge_empty() { let doc = merge_into_graph(Vec::new(), None); - if let Document::Graph(g) = doc { - assert_eq!(g.graph.id, "graph-merged-0"); - assert!(g.paths.is_empty()); - } else { - panic!("Expected Graph"); - } + assert_eq!(doc.graph.id, "graph-merged-0"); + assert!(doc.paths.is_empty()); } #[test] @@ -218,12 +124,8 @@ mod tests { let paths = vec![PathOrRef::Path(Box::new(p1))]; let doc = merge_into_graph(paths, Some("Test".to_string())); let json = doc.to_json().unwrap(); - let parsed = Document::from_json(&json).unwrap(); - if let Document::Graph(g) = parsed { - assert_eq!(g.paths.len(), 1); - } else { - panic!("Expected Graph after roundtrip"); - } + let parsed = Graph::from_json(&json).unwrap(); + assert_eq!(parsed.paths.len(), 1); } #[test] @@ -234,7 +136,7 @@ mod tests { let p1 = make_path("p1", vec![make_step("s1", "human:alex")]); let f1 = dir.path().join("doc1.json"); let mut file1 = std::fs::File::create(&f1).unwrap(); - write!(file1, "{}", Document::Path(p1).to_json().unwrap()).unwrap(); + write!(file1, "{}", Graph::from_path(p1).to_json().unwrap()).unwrap(); let result = run( vec![f1.to_str().unwrap().to_string()], @@ -256,10 +158,9 @@ mod tests { let f2 = dir.path().join("doc2.json"); let mut file1 = std::fs::File::create(&f1).unwrap(); let mut file2 = std::fs::File::create(&f2).unwrap(); - write!(file1, "{}", Document::Path(p1).to_json().unwrap()).unwrap(); - write!(file2, "{}", Document::Path(p2).to_json().unwrap()).unwrap(); + write!(file1, "{}", Graph::from_path(p1).to_json().unwrap()).unwrap(); + write!(file2, "{}", Graph::from_path(p2).to_json().unwrap()).unwrap(); - // run() prints to stdout — just verify it doesn't error let result = run( vec![ f1.to_str().unwrap().to_string(), diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index 86d46c5..0781bd6 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; -use toolpath::v1::{Document, query}; +use toolpath::v1::{Graph, PathOrRef, query}; #[derive(Subcommand, Debug)] pub enum QueryOp { @@ -59,24 +59,18 @@ pub fn run(op: QueryOp, pretty: bool) -> Result<()> { } } -fn read_doc(path: &std::path::Path) -> Result { +fn read_doc(path: &std::path::Path) -> Result { crate::io::read_document_auto(path) } -fn extract_steps(doc: &Document) -> (&[toolpath::v1::Step], Option<&str>) { - match doc { - Document::Path(p) => (p.steps.as_slice(), Some(p.path.head.as_str())), - Document::Graph(g) => { - // For graphs, use the first inline path - for p in &g.paths { - if let toolpath::v1::PathOrRef::Path(path) = p { - return (path.steps.as_slice(), Some(path.path.head.as_str())); - } - } - (&[], None) +/// Returns (steps, head) extracted from the graph's first inline path. +fn extract_steps(doc: &Graph) -> (&[toolpath::v1::Step], Option<&str>) { + for entry in &doc.paths { + if let PathOrRef::Path(path) = entry { + return (path.steps.as_slice(), Some(path.path.head.as_str())); } - Document::Step(_) => (&[], None), } + (&[], None) } fn print_steps(steps: &[&toolpath::v1::Step], pretty: bool) -> Result<()> { @@ -156,7 +150,7 @@ mod tests { use std::io::Write; use toolpath::v1::{Base, Path, PathIdentity, Step}; - fn make_path_doc() -> Document { + fn make_path_doc() -> Graph { let s1 = Step::new("s1", "human:alex", "2026-01-01T10:00:00Z") .with_raw_change("src/main.rs", "@@"); let s2 = Step::new("s2", "agent:claude", "2026-01-01T11:00:00Z") @@ -168,7 +162,7 @@ mod tests { let s3 = Step::new("s3", "human:alex", "2026-01-01T12:00:00Z") .with_parent("s2") .with_raw_change("src/main.rs", "@@"); - Document::Path(Path { + Graph::from_path(Path { path: PathIdentity { id: "p1".into(), base: Some(Base::vcs("github:org/repo", "abc")), @@ -180,7 +174,7 @@ mod tests { }) } - fn write_temp_doc(doc: &Document) -> tempfile::NamedTempFile { + fn write_temp_doc(doc: &Graph) -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); write!(f, "{}", doc.to_json().unwrap()).unwrap(); f.flush().unwrap(); @@ -188,7 +182,7 @@ mod tests { } #[test] - fn test_extract_steps_from_path() { + fn test_extract_steps_from_single_path_graph() { let doc = make_path_doc(); let (steps, head) = extract_steps(&doc); assert_eq!(steps.len(), 4); @@ -196,38 +190,13 @@ mod tests { } #[test] - fn test_extract_steps_from_step() { - let doc = Document::Step(Step::new("s1", "human:alex", "2026-01-01T00:00:00Z")); + fn test_extract_steps_from_empty_graph() { + let doc = Graph::new("g1"); let (steps, head) = extract_steps(&doc); assert!(steps.is_empty()); assert!(head.is_none()); } - #[test] - fn test_extract_steps_from_graph() { - let s1 = - Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_raw_change("f.rs", "@@"); - let path = Path { - path: PathIdentity { - id: "p1".into(), - base: None, - head: "s1".into(), - graph_ref: None, - }, - steps: vec![s1], - meta: None, - }; - let graph = toolpath::v1::Graph { - graph: toolpath::v1::GraphIdentity { id: "g1".into() }, - paths: vec![toolpath::v1::PathOrRef::Path(Box::new(path))], - meta: None, - }; - let doc = Document::Graph(graph); - let (steps, head) = extract_steps(&doc); - assert_eq!(steps.len(), 1); - assert_eq!(head, Some("s1")); - } - #[test] fn test_run_ancestors() { let doc = make_path_doc(); @@ -313,11 +282,11 @@ mod tests { } #[test] - fn test_run_dead_ends_on_step_doc() { - let doc = Document::Step(Step::new("s1", "human:alex", "2026-01-01T00:00:00Z")); + fn test_run_dead_ends_on_empty_graph() { + let doc = Graph::new("g1"); let f = write_temp_doc(&doc); let result = run_dead_ends(f.path().to_path_buf(), false); - // Should fail because Step has no head + // Empty graphs have no head step. assert!(result.is_err()); } diff --git a/crates/path-cli/src/cmd_render.rs b/crates/path-cli/src/cmd_render.rs index 28995b6..c481dc2 100644 --- a/crates/path-cli/src/cmd_render.rs +++ b/crates/path-cli/src/cmd_render.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use clap::Subcommand; use std::path::PathBuf; -use toolpath::v1::Document; +use toolpath::v1::Graph; #[derive(Subcommand, Debug)] pub enum RenderFormat { @@ -86,7 +86,7 @@ fn run_dot( std::io::stdin() .read_to_string(&mut buf) .context("Failed to read from stdin")?; - Document::from_json(&buf).context("Failed to parse Toolpath document")? + Graph::from_json(&buf).context("Failed to parse Toolpath document")? }; let options = toolpath_dot::RenderOptions { @@ -120,7 +120,7 @@ fn run_md( std::io::stdin() .read_to_string(&mut buf) .context("Failed to read from stdin")?; - Document::from_json(&buf).context("Failed to parse Toolpath document")? + Graph::from_json(&buf).context("Failed to parse Toolpath document")? }; let detail = match detail { @@ -150,10 +150,10 @@ mod tests { use std::io::Write; use toolpath::v1::{Path, PathIdentity, Step}; - fn make_doc() -> Document { + fn make_doc() -> Graph { let s1 = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_raw_change("f.rs", "@@"); - Document::Path(Path { + Graph::from_path(Path { path: PathIdentity { id: "p1".into(), base: None, @@ -271,7 +271,7 @@ mod tests { assert!(result.is_ok()); let content = std::fs::read_to_string(out.path()).unwrap(); - assert!(content.contains("# p1")); + assert!(content.contains("p1")); assert!(content.contains("## Timeline")); } @@ -313,7 +313,7 @@ mod tests { let content = std::fs::read_to_string(out.path()).unwrap(); assert!(content.starts_with("---\n")); - assert!(content.contains("type: path")); + assert!(content.contains("type:")); } #[test] diff --git a/crates/path-cli/src/cmd_show.rs b/crates/path-cli/src/cmd_show.rs index 7ad4fb0..0e1d37e 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -9,7 +9,7 @@ use anyhow::Result; use clap::Subcommand; -use toolpath::v1::Document; +use toolpath::v1::Graph; #[derive(Subcommand, Debug)] pub enum ShowSource { @@ -63,7 +63,7 @@ pub enum ShowSource { pub fn run(source: ShowSource) -> Result<()> { let path = derive_one(source)?; - let doc = Document::Path(path); + let doc = Graph::from_path(path); let opts = toolpath_md::RenderOptions { detail: toolpath_md::Detail::Summary, front_matter: false, diff --git a/crates/path-cli/src/cmd_track.rs b/crates/path-cli/src/cmd_track.rs index e5cd516..d585a9a 100644 --- a/crates/path-cli/src/cmd_track.rs +++ b/crates/path-cli/src/cmd_track.rs @@ -265,7 +265,7 @@ fn compute_diff(old: &str, new: &str) -> Option { } } -fn format_output(doc: v1::Document, pretty: bool) -> Result { +fn format_output(doc: v1::Graph, pretty: bool) -> Result { if pretty { doc.to_json_pretty() } else { @@ -583,7 +583,7 @@ fn run_annotate( fn run_export(session_path: PathBuf, pretty: bool) -> Result<()> { let (path_doc, _state) = load_session(&session_path)?; - let doc = v1::Document::Path(path_doc); + let doc = v1::Graph::from_path(path_doc); let json = format_output(doc, pretty)?; println!("{json}"); Ok(()) @@ -591,7 +591,7 @@ fn run_export(session_path: PathBuf, pretty: bool) -> Result<()> { fn run_close(session_path: PathBuf, pretty: bool, output: Option) -> Result<()> { let (path_doc, _state) = load_session(&session_path)?; - let doc = v1::Document::Path(path_doc); + let doc = v1::Graph::from_path(path_doc); let json = format_output(doc, pretty)?; if let Some(out) = output { @@ -787,8 +787,7 @@ mod tests { #[test] fn test_format_output_pretty_and_compact() { - let step = v1::Step::new("s1", "human:alex", "2026-01-01T00:00:00Z"); - let doc = v1::Document::Step(step); + let doc = v1::Graph::new("g1"); let pretty = format_output(doc.clone(), true).unwrap(); assert!(pretty.contains('\n')); @@ -996,17 +995,13 @@ mod tests { assert_eq!(def.identities[0].id, "alex@example.com"); // The session IS the document — actors survive as-is - let doc = v1::Document::Path(path_doc); + let doc = v1::Graph::from_path(path_doc); let json = doc.to_json_pretty().unwrap(); assert!(json.contains("alex@example.com")); - let parsed = v1::Document::from_json(&json).unwrap(); - match parsed { - v1::Document::Path(p) => { - let a = p.meta.unwrap().actors.unwrap(); - assert_eq!(a["human:alex"].name.as_deref(), Some("Alex")); - } - _ => panic!("Expected Path"), - } + let parsed = v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + let a = p.meta.as_ref().unwrap().actors.as_ref().unwrap(); + assert_eq!(a["human:alex"].name.as_deref(), Some("Alex")); } // ── record_step ────────────────────────────────────────────────────── @@ -1715,17 +1710,13 @@ mod tests { let json = std::fs::read_to_string(&output_path).unwrap(); assert!(!json.contains("\"track\"")); assert!(!json.contains("buffer_cache")); - let doc = v1::Document::from_json(&json).unwrap(); - match doc { - v1::Document::Path(p) => { - assert_eq!(p.path.id, "track-test-1"); - assert_eq!(p.path.head, "step-001"); - assert_eq!(p.steps.len(), 1); - // No title/source set → no meta block - assert!(p.meta.is_none()); - } - _ => panic!("Expected Path"), - } + let doc = v1::Graph::from_json(&json).unwrap(); + let p = doc.single_path().expect("single-path graph"); + assert_eq!(p.path.id, "track-test-1"); + assert_eq!(p.path.head, "step-001"); + assert_eq!(p.steps.len(), 1); + // No title/source set → no meta block + assert!(p.meta.is_none()); } #[test] @@ -1850,13 +1841,11 @@ mod tests { assert_eq!(exported.steps.len(), 1); // Roundtrip through JSON - let doc = v1::Document::Path(exported); + let doc = v1::Graph::from_path(exported); let json = doc.to_json_pretty().unwrap(); - let parsed = v1::Document::from_json(&json).unwrap(); - match parsed { - v1::Document::Path(p) => assert_eq!(p.path.id, "track-test-doc"), - _ => panic!("Expected Path"), - } + let parsed = v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + assert_eq!(p.path.id, "track-test-doc"); } #[test] @@ -1920,22 +1909,18 @@ mod tests { // Load = export-ready (track state already stripped) let (path_doc, _) = load_session(&session_path).unwrap(); - let doc = v1::Document::Path(path_doc.clone()); + let doc = v1::Graph::from_path(path_doc.clone()); // Serialize and parse back let json = doc.to_json_pretty().unwrap(); - let parsed = v1::Document::from_json(&json).unwrap(); - match parsed { - v1::Document::Path(p) => { - assert_eq!(p.steps.len(), 3); - assert_eq!(p.path.head, "step-003"); - // Verify DAG structure preserved - assert!(p.steps[0].step.parents.is_empty()); - assert_eq!(p.steps[1].step.parents, vec!["step-001"]); - assert_eq!(p.steps[2].step.parents, vec!["step-001"]); - } - _ => panic!("Expected Path"), - } + let parsed = v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + assert_eq!(p.steps.len(), 3); + assert_eq!(p.path.head, "step-003"); + // Verify DAG structure preserved + assert!(p.steps[0].step.parents.is_empty()); + assert_eq!(p.steps[1].step.parents, vec!["step-001"]); + assert_eq!(p.steps[2].step.parents, vec!["step-001"]); // Dead ends still work after roundtrip let dead = v1::query::dead_ends(&path_doc.steps, &path_doc.path.head); @@ -2026,34 +2011,30 @@ mod tests { let json = std::fs::read_to_string(&output).unwrap(); assert!(!json.contains("buffer_cache")); assert!(!json.contains("seq_to_step")); - let doc = v1::Document::from_json(&json).unwrap(); - match doc { - v1::Document::Path(p) => { - assert_eq!(p.steps.len(), 3); - assert_eq!(p.path.head, "step-003"); - assert!(p.path.base.is_some()); - - // step-001: root - assert!(p.steps[0].step.parents.is_empty()); - // step-002: child of step-001 - assert_eq!(p.steps[1].step.parents, vec!["step-001"]); - // step-003: also child of step-001 (branch!) - assert_eq!(p.steps[2].step.parents, vec!["step-001"]); - - // Note was set on step-002 (it was head when we noted) - let intent = p.steps[1].meta.as_ref().and_then(|m| m.intent.as_ref()); - assert_eq!(intent, Some(&"Add line 4".to_string())); - - // Dead ends: step-002 is not on ancestry of step-003 - let dead = v1::query::dead_ends(&p.steps, &p.path.head); - assert_eq!(dead.len(), 1); - assert_eq!(dead[0].step.id, "step-002"); - - // No title/source set → no meta block - assert!(p.meta.is_none()); - } - _ => panic!("Expected Path"), - } + let doc = v1::Graph::from_json(&json).unwrap(); + let p = doc.single_path().expect("single-path graph"); + assert_eq!(p.steps.len(), 3); + assert_eq!(p.path.head, "step-003"); + assert!(p.path.base.is_some()); + + // step-001: root + assert!(p.steps[0].step.parents.is_empty()); + // step-002: child of step-001 + assert_eq!(p.steps[1].step.parents, vec!["step-001"]); + // step-003: also child of step-001 (branch!) + assert_eq!(p.steps[2].step.parents, vec!["step-001"]); + + // Note was set on step-002 (it was head when we noted) + let intent = p.steps[1].meta.as_ref().and_then(|m| m.intent.as_ref()); + assert_eq!(intent, Some(&"Add line 4".to_string())); + + // Dead ends: step-002 is not on ancestry of step-003 + let dead = v1::query::dead_ends(&p.steps, &p.path.head); + assert_eq!(dead.len(), 1); + assert_eq!(dead[0].step.id, "step-002"); + + // No title/source set → no meta block + assert!(p.meta.is_none()); } // ── record_step --source ────────────────────────────────────────────── @@ -2136,17 +2117,13 @@ mod tests { assert_eq!(source.extra["dirty"], serde_json::json!(true)); // Roundtrip through JSON - let doc = v1::Document::Path(path_doc); + let doc = v1::Graph::from_path(path_doc); let json = doc.to_json_pretty().unwrap(); - let parsed = v1::Document::from_json(&json).unwrap(); - match parsed { - v1::Document::Path(p) => { - let s = p.steps[0].meta.as_ref().unwrap().source.as_ref().unwrap(); - assert_eq!(s.extra["branch"], serde_json::json!("main")); - assert_eq!(s.extra["dirty"], serde_json::json!(true)); - } - _ => panic!("Expected Path"), - } + let parsed = v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + let s = p.steps[0].meta.as_ref().unwrap().source.as_ref().unwrap(); + assert_eq!(s.extra["branch"], serde_json::json!("main")); + assert_eq!(s.extra["dirty"], serde_json::json!(true)); } #[test] @@ -2167,20 +2144,16 @@ mod tests { // Export (load strips track state) let (path_doc, _) = load_session(&session_path).unwrap(); - let doc = v1::Document::Path(path_doc); + let doc = v1::Graph::from_path(path_doc); let json = doc.to_json_pretty().unwrap(); // Parse back and verify source persisted - let parsed = v1::Document::from_json(&json).unwrap(); - match parsed { - v1::Document::Path(p) => { - let source = p.steps[0].meta.as_ref().unwrap().source.as_ref().unwrap(); - assert_eq!(source.vcs_type, "git"); - assert_eq!(source.revision, "def456"); - assert_eq!(source.change_id.as_deref(), Some("I1234")); - } - _ => panic!("Expected Path"), - } + let parsed = v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + let source = p.steps[0].meta.as_ref().unwrap().source.as_ref().unwrap(); + assert_eq!(source.vcs_type, "git"); + assert_eq!(source.revision, "def456"); + assert_eq!(source.change_id.as_deref(), Some("I1234")); } // ── run_annotate ────────────────────────────────────────────────────── diff --git a/crates/path-cli/src/cmd_validate.rs b/crates/path-cli/src/cmd_validate.rs index 5b1cb2f..e4f96c5 100644 --- a/crates/path-cli/src/cmd_validate.rs +++ b/crates/path-cli/src/cmd_validate.rs @@ -1,6 +1,6 @@ use anyhow::Result; use std::path::PathBuf; -use toolpath::v1::Document; +use toolpath::v1::Graph; use crate::io::read_document_auto; @@ -14,17 +14,19 @@ pub fn run(input: PathBuf) -> Result<()> { } } -fn describe(doc: &Document) -> String { - match doc { - Document::Graph(g) => format!("Graph (id: {})", g.graph.id), - Document::Path(p) => format!("Path (id: {}, {} steps)", p.path.id, p.steps.len()), - Document::Step(s) => format!("Step (id: {})", s.step.id), - } +fn describe(doc: &Graph) -> String { + let path_count = doc.paths.len(); + format!( + "Graph (id: {}, {} {})", + doc.graph.id, + path_count, + if path_count == 1 { "path" } else { "paths" } + ) } #[cfg(test)] fn validate_content(content: &str) -> Result<()> { - match Document::from_json(content) { + match Graph::from_json(content) { Ok(doc) => { println!("Valid: {}", describe(&doc)); Ok(()) @@ -39,20 +41,14 @@ mod tests { use std::io::Write; #[test] - fn test_validate_valid_step() { - let json = r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"2026-01-01T00:00:00Z"},"change":{}}}"#; - assert!(validate_content(json).is_ok()); - } - - #[test] - fn test_validate_valid_path() { - let json = r#"{"Path":{"path":{"id":"p1","head":"s1"},"steps":[]}}"#; + fn test_validate_empty_graph() { + let json = r#"{"graph":{"id":"g1"},"paths":[]}"#; assert!(validate_content(json).is_ok()); } #[test] - fn test_validate_valid_graph() { - let json = r#"{"Graph":{"graph":{"id":"g1"},"paths":[]}}"#; + fn test_validate_single_path_graph() { + let json = r#"{"graph":{"id":"g1"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[{"step":{"id":"s1","actor":"human:alex","timestamp":"2026-01-01T00:00:00Z"},"change":{}}]}]}"#; assert!(validate_content(json).is_ok()); } @@ -62,14 +58,14 @@ mod tests { } #[test] - fn test_validate_invalid_structure() { - assert!(validate_content(r#"{"Unknown":{}}"#).is_err()); + fn test_validate_missing_required_field() { + assert!(validate_content(r#"{"paths":[]}"#).is_err()); } #[test] fn test_run_with_temp_file() { let mut f = tempfile::NamedTempFile::new().unwrap(); - write!(f, r#"{{"Step":{{"step":{{"id":"s1","actor":"human:alex","timestamp":"2026-01-01T00:00:00Z"}},"change":{{}}}}}}"#).unwrap(); + write!(f, r#"{{"graph":{{"id":"g1"}},"paths":[]}}"#).unwrap(); f.flush().unwrap(); assert!(run(f.path().to_path_buf()).is_ok()); } diff --git a/crates/path-cli/src/io.rs b/crates/path-cli/src/io.rs index a37dea9..5641bb2 100644 --- a/crates/path-cli/src/io.rs +++ b/crates/path-cli/src/io.rs @@ -6,26 +6,24 @@ use anyhow::{Context, Result}; use std::io::BufReader; use std::path::Path as FsPath; -use toolpath::v1::{Document, Path}; +use toolpath::v1::Graph; -/// Read a Toolpath document from a file, auto-detecting the format. +/// Read a Toolpath [`Graph`] from a file, auto-detecting the format. /// -/// - Files whose name ends with `.path.jsonl` are parsed as JSONL `Path` -/// streams and returned as `Document::Path`. -/// - All other files are parsed as canonical `{"Step"|"Path"|"Graph": ...}` -/// JSON. -pub fn read_document_auto(path: &FsPath) -> Result { +/// - Files whose name ends with `.path.jsonl` are parsed through the JSONL +/// streaming reader and wrapped as a single-path graph. +/// - All other files are parsed as canonical `Graph` JSON. +pub fn read_document_auto(path: &FsPath) -> Result { if is_path_jsonl(path) { let file = std::fs::File::open(path) .with_context(|| format!("failed to open {}", path.display()))?; let reader = BufReader::new(file); - let p = Path::from_jsonl_reader(reader) - .with_context(|| format!("failed to parse JSONL {}", path.display()))?; - Ok(Document::Path(p)) + Graph::from_jsonl_reader(reader) + .with_context(|| format!("failed to parse JSONL {}", path.display())) } else { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; - Document::from_json(&content).with_context(|| format!("failed to parse {}", path.display())) + Graph::from_json(&content).with_context(|| format!("failed to parse {}", path.display())) } } diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 03a0830..6af4cbd 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -104,7 +104,8 @@ fn derive_git_produces_path() { .arg(&branch) .assert() .success() - .stdout(predicate::str::contains("\"Path\"")) + .stdout(predicate::str::contains("\"graph\":")) + .stdout(predicate::str::contains("\"paths\":")) .stdout(predicate::str::contains("\"head\":")) .stdout(predicate::str::contains("\"steps\"")); } @@ -126,7 +127,7 @@ fn derive_git_has_correct_actor() { assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let path = &json["Path"]; + let path = &json["paths"][0]; // Actor is derived from git author email username (alice@example.com → alice) let step = &path["steps"][0]; @@ -156,7 +157,7 @@ fn derive_git_has_change_with_diff() { assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let step = &json["Path"]["steps"][0]; + let step = &json["paths"][0]["steps"][0]; // The step should have a change for main.rs with a raw diff let change = &step["change"]["main.rs"]; @@ -188,7 +189,7 @@ fn derive_git_has_intent_from_commit_message() { assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let step = &json["Path"]["steps"][0]; + let step = &json["paths"][0]["steps"][0]; // meta.intent is the commit message assert_eq!(step["meta"]["intent"], "fix the bug"); @@ -211,7 +212,7 @@ fn derive_git_has_base_uri() { assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let base = &json["Path"]["path"]["base"]; + let base = &json["paths"][0]["path"]["base"]; // base.uri should be a file:// URL pointing to the repo let uri = base["uri"].as_str().unwrap(); @@ -311,7 +312,8 @@ fn merge_produces_graph() { .arg(examples_dir().join("path-02-local-session.path.json")) .assert() .success() - .stdout(predicate::str::contains("\"Graph\"")); + .stdout(predicate::str::contains("\"graph\":")) + .stdout(predicate::str::contains("\"paths\":")); } // ── .path.jsonl input ──────────────────────────────────────────────── @@ -324,7 +326,7 @@ fn validate_accepts_path_jsonl() { .arg(examples_dir().join("path-02-local-session.path.jsonl")) .assert() .success() - .stdout(predicate::str::contains("Valid: Path")); + .stdout(predicate::str::contains("Valid: Graph")); } #[test] @@ -378,7 +380,8 @@ fn merge_accepts_path_jsonl() { .arg(examples_dir().join("path-02-local-session.path.jsonl")) .assert() .success() - .stdout(predicate::str::contains("\"Graph\"")); + .stdout(predicate::str::contains("\"graph\":")) + .stdout(predicate::str::contains("\"paths\":")); } // ── Auth ───────────────────────────────────────────────────────────── @@ -451,7 +454,8 @@ fn import_git_no_cache_emits_stdout_json() { .arg(&branch) .assert() .success() - .stdout(predicate::str::contains("\"Path\"")) + .stdout(predicate::str::contains("\"graph\":")) + .stdout(predicate::str::contains("\"paths\":")) .stdout(predicate::str::contains("\"steps\"")); } @@ -641,6 +645,7 @@ fn derive_alias_still_works_with_warning() { .arg(&branch) .assert() .success() - .stdout(predicate::str::contains("\"Path\"")) + .stdout(predicate::str::contains("\"graph\":")) + .stdout(predicate::str::contains("\"paths\":")) .stderr(predicate::str::contains("deprecated")); } diff --git a/crates/path-cli/tests/roundtrip.rs b/crates/path-cli/tests/roundtrip.rs index eb67b35..8caa973 100644 --- a/crates/path-cli/tests/roundtrip.rs +++ b/crates/path-cli/tests/roundtrip.rs @@ -235,7 +235,7 @@ fn test_cli_project_command() { meta: None, }; - let doc = toolpath::v1::Document::Path(path); + let doc = toolpath::v1::Graph::from_path(path); // 2. Write the Path document to a temp file. let temp = tempfile::TempDir::new().unwrap(); diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap index 347fcd6..d1eb6b4 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_01_minimal.snap @@ -2,11 +2,16 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-01-minimal.json\")" --- -# step-001 +# path-step-001 -**Actor:** `human:alex` -**Timestamp:** 2026-01-29T10:00:00Z +**Head:** `step-001` +**Changes:** +1 −1 across 1 files +**Steps:** 1 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline -## Changes +### step-001 — human:alex [head] + +**Timestamp:** 2026-01-29T10:00:00Z - `src/main.rs` (+1 -1) diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap index cbd2543..07b5216 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_02_agent.snap @@ -2,9 +2,16 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-02-agent.json\")" --- -# step-002 +# path-step-002 + +**Head:** `step-002` +**Changes:** +24 −0 across 2 files +**Steps:** 1 | **Artifacts:** 2 | **Dead ends:** 0 + +## Timeline + +### step-002 — agent:claude-code/session-abc123 [head] -**Actor:** `agent:claude-code/session-abc123` **Timestamp:** 2026-01-29T10:05:00Z **Parents:** `step-001` @@ -14,7 +21,5 @@ expression: "render_md(\"step-02-agent.json\")" - **implements:** `doc://design/input-validation-2026q1.md` - **reasoning:** `agent://claude-code/session-abc123/turn/3` -## Changes - - `src/auth/mod.rs` (+1 -0) - `src/auth/validator.rs` (+23 -0, rust.add_items) diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap index c3427d0..d89c5e7 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_03_formatter.snap @@ -2,14 +2,19 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-03-formatter.json\")" --- -# step-003 +# path-step-003 + +**Head:** `step-003` +**Changes:** +8 −3 across 1 files +**Steps:** 1 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-003 — tool:rustfmt/1.7.0 [head] -**Actor:** `tool:rustfmt/1.7.0` **Timestamp:** 2026-01-29T10:05:30Z **Parents:** `step-002` > Automatic code formatting -## Changes - - `src/auth/validator.rs` (+8 -3) diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap index 0fb3125..812472b 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_04_human_refinement.snap @@ -2,9 +2,16 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-04-human-refinement.json\")" --- -# step-004 +# path-step-004 + +**Head:** `step-004` +**Changes:** +2 −2 across 1 files +**Steps:** 1 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-004 — human:alex [head] -**Actor:** `human:alex` **Timestamp:** 2026-01-29T10:15:00Z **Parents:** `step-003` @@ -12,6 +19,4 @@ expression: "render_md(\"step-04-human-refinement.json\")" - **refines:** `toolpath://step-002` -## Changes - - `src/auth/validator.rs` (+2 -2, rust.modify_expressions) diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap index fa97c8f..90a8c33 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_05_dead_end.snap @@ -2,14 +2,19 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-05-dead-end.json\")" --- -# step-002a +# path-step-002a + +**Head:** `step-002a` +**Changes:** +12 −0 across 1 files +**Steps:** 1 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-002a — agent:claude-code/session-abc123 [head] -**Actor:** `agent:claude-code/session-abc123` **Timestamp:** 2026-01-29T10:03:00Z **Parents:** `step-001` > Validate email addresses using regex pattern matching -## Changes - - `src/auth/validator.rs` (+12 -0, rust.add_items) diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap index 1b7eb40..7a82f73 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_06_signed.snap @@ -2,13 +2,18 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-06-signed.json\")" --- -# step-001 +# path-step-001 + +**Head:** `step-001` +**Changes:** +1 −1 across 1 files +**Steps:** 1 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-001 — human:alex [head] -**Actor:** `human:alex` **Timestamp:** 2026-01-29T10:00:00Z > Fix greeting punctuation -## Changes - - `src/main.rs` (+1 -1) diff --git a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap index 7c6c73f..3a8c7ce 100644 --- a/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap +++ b/crates/path-cli/tests/snapshots/render_md_snapshots__render_md_step_07_merge.snap @@ -2,14 +2,18 @@ source: crates/path-cli/tests/render_md_snapshots.rs expression: "render_md(\"step-07-merge.json\")" --- -# step-004 +# path-step-004 + +**Head:** `step-004` +**Steps:** 1 | **Artifacts:** 1 | **Dead ends:** 0 + +## Timeline + +### step-004 — human:alex [head] -**Actor:** `human:alex` **Timestamp:** 2026-01-29T12:00:00Z **Parents:** `step-002a`, `step-003b` > Merge validation improvements from feature-A with logging from feature-B -## Changes - - `src/auth/validator.rs` diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index a2be974..710f09b 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.5.1" +version = "0.6.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,5 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.5.0" } +path-cli = { path = "../path-cli", version = "0.6.0" } anyhow = "1.0" + +[workspace] diff --git a/crates/toolpath-codex/src/derive.rs b/crates/toolpath-codex/src/derive.rs index acd70f7..b4b9a16 100644 --- a/crates/toolpath-codex/src/derive.rs +++ b/crates/toolpath-codex/src/derive.rs @@ -525,7 +525,7 @@ mod tests { use crate::CodexConvo; use std::fs; use tempfile::TempDir; - use toolpath::v1::Document; + use toolpath::v1::Graph; fn fixture_session(body: &str) -> (TempDir, CodexConvo, String) { let temp = TempDir::new().unwrap(); @@ -644,18 +644,14 @@ mod tests { let (_t, mgr, id) = fixture_session(&minimal_body()); let session = mgr.read_session(&id).unwrap(); let path = derive_path(&session, &DeriveConfig::default()); - let doc = Document::Path(path); + let doc = Graph::from_path(path); let json = doc.to_json().unwrap(); // Round-trip through the validator (it just needs to parse). - let parsed = Document::from_json(&json).unwrap(); - match parsed { - Document::Path(p) => { - assert!(!p.steps.is_empty()); - let ancestors = toolpath::v1::query::ancestors(&p.steps, &p.path.head); - assert_eq!(ancestors.len(), p.steps.len(), "all steps on head ancestry"); - } - _ => panic!("expected Path"), - } + let parsed = Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + assert!(!p.steps.is_empty()); + let ancestors = toolpath::v1::query::ancestors(&p.steps, &p.path.head); + assert_eq!(ancestors.len(), p.steps.len(), "all steps on head ancestry"); } #[test] diff --git a/crates/toolpath-codex/tests/fixture_roundtrip.rs b/crates/toolpath-codex/tests/fixture_roundtrip.rs index 63ff412..ef1ef7b 100644 --- a/crates/toolpath-codex/tests/fixture_roundtrip.rs +++ b/crates/toolpath-codex/tests/fixture_roundtrip.rs @@ -222,17 +222,13 @@ fn derive_path_produces_file_artifacts_with_raw_diffs() { fn derive_path_validates_as_path_document() { let s = session(); let path = derive::derive_path(&s, &derive::DeriveConfig::default()); - let doc = toolpath::v1::Document::Path(path); + let doc = toolpath::v1::Graph::from_path(path); let json = doc.to_json().unwrap(); - let parsed = toolpath::v1::Document::from_json(&json).unwrap(); - match parsed { - toolpath::v1::Document::Path(p) => { - assert!(!p.steps.is_empty()); - let anc = toolpath::v1::query::ancestors(&p.steps, &p.path.head); - assert_eq!(anc.len(), p.steps.len(), "all steps on head ancestry"); - } - _ => panic!("expected Path document"), - } + let parsed = toolpath::v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + assert!(!p.steps.is_empty()); + let anc = toolpath::v1::query::ancestors(&p.steps, &p.path.head); + assert_eq!(anc.len(), p.steps.len(), "all steps on head ancestry"); } #[test] diff --git a/crates/toolpath-dot/Cargo.toml b/crates/toolpath-dot/Cargo.toml index 6715607..8fb79a4 100644 --- a/crates/toolpath-dot/Cargo.toml +++ b/crates/toolpath-dot/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-dot" -version = "0.1.3" +version = "0.2.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-dot/README.md b/crates/toolpath-dot/README.md index bf72c56..45db736 100644 --- a/crates/toolpath-dot/README.md +++ b/crates/toolpath-dot/README.md @@ -9,19 +9,19 @@ interleave — color-coded by actor type. ## Overview -Renders any Toolpath `Document` (Step, Path, or Graph) as a Graphviz DOT string. Steps are colored by actor type, dead ends are highlighted, and the DAG structure is preserved visually. +Renders a Toolpath `Graph` (the single root document type) as a Graphviz DOT string. Steps are colored by actor type, dead ends are highlighted, and the DAG structure is preserved visually. Single-path graphs use a path-focused layout; multi-path graphs use clustered subgraphs. Depends only on `toolpath` -- no external rendering libraries. You'll need [Graphviz](https://graphviz.org/) installed to convert DOT output to images (`dot -Tpng`). ## Usage ```rust -use toolpath::v1::Document; +use toolpath::v1::Graph; use toolpath_dot::{render, RenderOptions}; -let json_str = r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"T"},"change":{}}}"#; -let doc = Document::from_json(json_str).unwrap(); -let dot = render(&doc, &RenderOptions::default()); +let json_str = r#"{"graph":{"id":"g1"},"paths":[]}"#; +let graph = Graph::from_json(json_str).unwrap(); +let dot = render(&graph, &RenderOptions::default()); assert!(dot.contains("digraph")); ``` @@ -47,7 +47,7 @@ let options = RenderOptions { | Function | Description | |---|---| -| `render(doc, options)` | Render any `Document` variant | +| `render(graph, options)` | Render a `Graph` (single-path graphs use the path layout) | | `render_step(step, options)` | Render a single Step | | `render_path(path, options)` | Render a Path with its step DAG | | `render_graph(graph, options)` | Render a Graph with subgraph clusters per path | diff --git a/crates/toolpath-dot/src/lib.rs b/crates/toolpath-dot/src/lib.rs index 5710b6c..88ceee0 100644 --- a/crates/toolpath-dot/src/lib.rs +++ b/crates/toolpath-dot/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use toolpath::v1::{Document, Graph, Path, PathOrRef, Step, query}; +use toolpath::v1::{Graph, Path, PathOrRef, Step, query}; /// Options controlling what information is rendered in the DOT output. pub struct RenderOptions { @@ -24,13 +24,17 @@ impl Default for RenderOptions { } } -/// Render any Toolpath [`Document`] variant to a Graphviz DOT string. -pub fn render(doc: &Document, options: &RenderOptions) -> String { - match doc { - Document::Graph(g) => render_graph(g, options), - Document::Path(p) => render_path(p, options), - Document::Step(s) => render_step(s, options), +/// Render a Toolpath [`Graph`] to a Graphviz DOT string. The graph is the +/// single root document type — single-path graphs render through the inline +/// path-level layout for cleaner output, multi-path graphs use the cluster +/// layout, and an empty graph renders an empty digraph. +pub fn render(graph: &Graph, options: &RenderOptions) -> String { + if graph.paths.len() == 1 + && let PathOrRef::Path(p) = &graph.paths[0] + { + return render_path(p, options); } + render_graph(graph, options) } /// Render a single [`Step`] as a DOT digraph. @@ -830,42 +834,69 @@ mod tests { // ── render (dispatch) ────────────────────────────────────────────── #[test] - fn test_render_dispatches_step() { - let step = make_step("s1", "human:alex", &[]); - let doc = Document::Step(step); + fn test_render_single_path_graph_uses_path_layout() { + let path = Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![make_step("s1", "human:alex", &[])], + meta: None, + }; + let graph = Graph::from_path(path); let opts = RenderOptions::default(); - let dot = render(&doc, &opts); - assert!(dot.contains("\"s1\"")); + let dot = render(&graph, &opts); + assert!(dot.contains("cluster_legend")); } #[test] - fn test_render_dispatches_path() { - let path = Path { + fn test_render_multi_path_graph_uses_graph_layout() { + let s1 = make_step("s1", "human:alex", &[]); + let s3 = make_step("s3", "human:bob", &[]); + let path1 = Path { path: PathIdentity { id: "p1".into(), base: None, head: "s1".into(), graph_ref: None, }, - steps: vec![make_step("s1", "human:alex", &[])], + steps: vec![s1], + meta: None, + }; + let path2 = Path { + path: PathIdentity { + id: "p2".into(), + base: None, + head: "s3".into(), + graph_ref: None, + }, + steps: vec![s3], + meta: None, + }; + let graph = Graph { + graph: GraphIdentity { id: "g1".into() }, + paths: vec![ + PathOrRef::Path(Box::new(path1)), + PathOrRef::Path(Box::new(path2)), + ], meta: None, }; - let doc = Document::Path(path); let opts = RenderOptions::default(); - let dot = render(&doc, &opts); - assert!(dot.contains("cluster_legend")); + let dot = render(&graph, &opts); + assert!(dot.contains("compound=true")); } #[test] - fn test_render_dispatches_graph() { + fn test_render_empty_graph_uses_graph_layout() { let graph = Graph { graph: GraphIdentity { id: "g1".into() }, paths: vec![], meta: None, }; - let doc = Document::Graph(graph); let opts = RenderOptions::default(); - let dot = render(&doc, &opts); + let dot = render(&graph, &opts); assert!(dot.contains("compound=true")); } } diff --git a/crates/toolpath-gemini/tests/fixture_roundtrip.rs b/crates/toolpath-gemini/tests/fixture_roundtrip.rs index 89de9af..de65fdb 100644 --- a/crates/toolpath-gemini/tests/fixture_roundtrip.rs +++ b/crates/toolpath-gemini/tests/fixture_roundtrip.rs @@ -80,18 +80,14 @@ fn fixture_derives_to_valid_path() { include_thinking: false, }, ); - let doc = toolpath::v1::Document::Path(path); + let doc = toolpath::v1::Graph::from_path(path); let json = doc.to_json().unwrap(); // Roundtrip verifies serde is well-formed - let parsed = toolpath::v1::Document::from_json(&json).unwrap(); - match parsed { - toolpath::v1::Document::Path(p) => { - assert!(p.path.id.starts_with("path-gemini-")); - assert!(!p.steps.is_empty()); - // Ancestors of head must cover all steps in a linear session - let ancestors = toolpath::v1::query::ancestors(&p.steps, &p.path.head); - assert_eq!(ancestors.len(), p.steps.len()); - } - _ => panic!("expected Path document"), - } + let parsed = toolpath::v1::Graph::from_json(&json).unwrap(); + let p = parsed.single_path().expect("single-path graph"); + assert!(p.path.id.starts_with("path-gemini-")); + assert!(!p.steps.is_empty()); + // Ancestors of head must cover all steps in a linear session + let ancestors = toolpath::v1::query::ancestors(&p.steps, &p.path.head); + assert_eq!(ancestors.len(), p.steps.len()); } diff --git a/crates/toolpath-gemini/tests/projection_roundtrip.rs b/crates/toolpath-gemini/tests/projection_roundtrip.rs index 947838a..d72e672 100644 --- a/crates/toolpath-gemini/tests/projection_roundtrip.rs +++ b/crates/toolpath-gemini/tests/projection_roundtrip.rs @@ -12,7 +12,7 @@ //! non-canonical fields like `resultDisplay` structured payloads that //! only survive via provider extras). -use toolpath::v1::{Document, Path}; +use toolpath::v1::{Graph, Path}; use toolpath_convo::{ ConversationProjector, ConversationView, DeriveConfig, derive_path, extract_conversation, }; @@ -44,13 +44,12 @@ fn roundtrip(source: &Conversation) -> (ConversationView, Conversation, Path) { // Serialize & re-parse the Path to simulate on-disk storage. let path = derive_path(&view_forward, &DeriveConfig::default()); - let doc = Document::Path(path.clone()); - let json = serde_json::to_string(&doc).expect("serialize Document"); - let back: Document = serde_json::from_str(&json).expect("parse Document"); - let reparsed = match back { - Document::Path(p) => p, - other => panic!("expected Path, got {:?}", other), - }; + let doc = Graph::from_path(path.clone()); + let json = serde_json::to_string(&doc).expect("serialize Graph"); + let back: Graph = serde_json::from_str(&json).expect("parse Graph"); + let reparsed = back + .into_single_path() + .expect("single-path graph"); let view_back = extract_conversation(&reparsed); let projector = GeminiProjector::new() diff --git a/crates/toolpath-git/Cargo.toml b/crates/toolpath-git/Cargo.toml index c4c4aa7..915dbf2 100644 --- a/crates/toolpath-git/Cargo.toml +++ b/crates/toolpath-git/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-git" -version = "0.1.4" +version = "0.2.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-git/src/lib.rs b/crates/toolpath-git/src/lib.rs index 68b8cf3..1b790e4 100644 --- a/crates/toolpath-git/src/lib.rs +++ b/crates/toolpath-git/src/lib.rs @@ -156,30 +156,29 @@ mod native { use git2::{Commit, DiffOptions, Oid, Repository}; use std::collections::HashMap; use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, - Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource, + ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, Path, + PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource, }; use super::{BranchInfo, BranchSpec, DeriveConfig}; - /// Derive a Toolpath [`Document`] from the given repository and branch names. + /// Derive a Toolpath [`Graph`] from the given repository and branch names. /// /// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax). - /// A single branch produces a [`Document::Path`]; multiple branches produce a - /// [`Document::Graph`]. + /// A single branch yields a single-path graph (one entry in `paths`); multiple + /// branches yield a multi-path graph. pub fn derive( repo: &Repository, branches: &[String], config: &DeriveConfig, - ) -> Result { + ) -> Result { let branch_specs: Vec = branches.iter().map(|s| BranchSpec::parse(s)).collect(); if branch_specs.len() == 1 { let path_doc = derive_path(repo, &branch_specs[0], config)?; - Ok(Document::Path(path_doc)) + Ok(Graph::from_path(path_doc)) } else { - let graph_doc = derive_graph(repo, &branch_specs, config)?; - Ok(Document::Graph(graph_doc)) + derive_graph(repo, &branch_specs, config) } } @@ -793,15 +792,12 @@ mod native { }; let default = find_default_branch(&repo).unwrap_or("main".to_string()); - let result = derive(&repo, &[default], &config).unwrap(); - - match result { - Document::Path(path) => { - assert!(!path.steps.is_empty(), "Expected at least one step"); - assert!(path.path.base.is_some()); - } - _ => panic!("Expected Document::Path for single branch"), - } + let graph = derive(&repo, &[default], &config).unwrap(); + let path = graph + .single_path() + .expect("single-branch derive yields a single-path graph"); + assert!(!path.steps.is_empty(), "Expected at least one step"); + assert!(path.path.base.is_some()); } #[test] @@ -837,16 +833,10 @@ mod native { base: None, }; - let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap(); - - match result { - Document::Graph(graph) => { - assert_eq!(graph.paths.len(), 2); - assert!(graph.meta.is_some()); - assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph"); - } - _ => panic!("Expected Document::Graph for multiple branches"), - } + let graph = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap(); + assert_eq!(graph.paths.len(), 2); + assert!(graph.meta.is_some()); + assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph"); } #[test] @@ -891,13 +881,11 @@ mod native { base: Some(oid1.to_string()), }; - let result = derive(&repo, &[default], &config).unwrap(); - match result { - Document::Path(path) => { - assert!(!path.steps.is_empty()); - } - _ => panic!("Expected Document::Path"), - } + let graph = derive(&repo, &[default], &config).unwrap(); + let path = graph + .single_path() + .expect("single-branch derive yields a single-path graph"); + assert!(!path.steps.is_empty()); } #[test] @@ -965,7 +953,7 @@ mod native { base: Some(oid1.to_string()), }; - let result = derive( + let g = derive( &repo, &[ "b1".to_string(), @@ -977,12 +965,7 @@ mod native { ) .unwrap(); - match result { - Document::Graph(g) => { - assert!(g.graph.id.contains("4-branches")); - } - _ => panic!("Expected Graph"), - } + assert!(g.graph.id.contains("4-branches")); } #[test] diff --git a/crates/toolpath-md/Cargo.toml b/crates/toolpath-md/Cargo.toml index 30fd2a6..9a7ebe0 100644 --- a/crates/toolpath-md/Cargo.toml +++ b/crates/toolpath-md/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-md" -version = "0.2.1" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-md/README.md b/crates/toolpath-md/README.md index 8005847..1e4772f 100644 --- a/crates/toolpath-md/README.md +++ b/crates/toolpath-md/README.md @@ -9,23 +9,25 @@ abandoned"), giving models anti-examples alongside the successful path. ## Overview -Renders any Toolpath `Document` (Step, Path, or Graph) as a Markdown string. -Steps are topologically sorted, dead ends are marked and summarized, and the -output includes enough anchoring information (step IDs, artifact paths, actor -strings) for an LLM to reference back into the original document. +Renders a Toolpath `Graph` (the single root document type) as a Markdown +string. Single-path graphs use the path-focused layout; multi-path graphs use +the cross-path layout. Steps are topologically sorted, dead ends are marked +and summarized, and the output includes enough anchoring information (step +IDs, artifact paths, actor strings) for an LLM to reference back into the +original document. Depends only on `toolpath` — no template engines, no external dependencies. ## Usage ```rust -use toolpath::v1::Document; +use toolpath::v1::Graph; use toolpath_md::{render, RenderOptions}; -let json_str = r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"T"},"change":{}}}"#; -let doc = Document::from_json(json_str).unwrap(); -let md = render(&doc, &RenderOptions::default()); -assert!(md.contains("# s1")); +let json_str = r#"{"graph":{"id":"g1"},"paths":[]}"#; +let graph = Graph::from_json(json_str).unwrap(); +let md = render(&graph, &RenderOptions::default()); +assert!(md.contains("g1")); ``` Pipe into an LLM for contextual assistance: @@ -61,7 +63,7 @@ dead end count). Useful for LLM workflows that parse structured preambles. | Function | Description | |---|---| -| `render(doc, options)` | Render any `Document` variant | +| `render(graph, options)` | Render a `Graph` (single-path graphs use the path layout) | | `render_step(step, options)` | Render a single Step | | `render_path(path, options)` | Render a Path with its step DAG | | `render_graph(graph, options)` | Render a Graph with all paths | diff --git a/crates/toolpath-md/src/lib.rs b/crates/toolpath-md/src/lib.rs index 1915b50..b95f1cd 100644 --- a/crates/toolpath-md/src/lib.rs +++ b/crates/toolpath-md/src/lib.rs @@ -5,7 +5,7 @@ mod source; use std::collections::{HashMap, HashSet}; use std::fmt::Write; -use toolpath::v1::{ArtifactChange, Document, Graph, Path, PathOrRef, Step, query}; +use toolpath::v1::{ArtifactChange, Graph, Path, PathOrRef, Step, query}; /// Detail level for the rendered markdown. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -34,28 +34,39 @@ impl Default for RenderOptions { } } -/// Render any Toolpath [`Document`] variant to a Markdown string. +/// Render a Toolpath [`Graph`] as a Markdown string. Single-path graphs use +/// the path-focused layout, multi-path graphs use the cross-path layout. /// /// # Examples /// /// ``` -/// use toolpath::v1::{Document, Step}; +/// use toolpath::v1::{Graph, Path, PathIdentity, Step}; /// use toolpath_md::{render, RenderOptions}; /// /// let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z") /// .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") /// .with_intent("Fix greeting"); -/// let doc = Document::Step(step); -/// let md = render(&doc, &RenderOptions::default()); -/// assert!(md.contains("# s1")); +/// let path = Path { +/// path: PathIdentity { +/// id: "p1".into(), +/// base: None, +/// head: "s1".into(), +/// graph_ref: None, +/// }, +/// steps: vec![step], +/// meta: None, +/// }; +/// let graph = Graph::from_path(path); +/// let md = render(&graph, &RenderOptions::default()); /// assert!(md.contains("human:alex")); /// ``` -pub fn render(doc: &Document, options: &RenderOptions) -> String { - match doc { - Document::Graph(g) => render_graph(g, options), - Document::Path(p) => render_path(p, options), - Document::Step(s) => render_step(s, options), +pub fn render(graph: &Graph, options: &RenderOptions) -> String { + if graph.paths.len() == 1 + && let PathOrRef::Path(p) = &graph.paths[0] + { + return render_path(p, options); } + render_graph(graph, options) } /// Render a single [`Step`] as Markdown. @@ -1517,15 +1528,7 @@ mod tests { // ── render (dispatch) ──────────────────────────────────────────────── #[test] - fn test_render_dispatches_step() { - let step = make_step("s1", "human:alex", &[]); - let doc = Document::Step(step); - let md = render(&doc, &RenderOptions::default()); - assert!(md.contains("# s1")); - } - - #[test] - fn test_render_dispatches_path() { + fn test_render_single_path_graph_uses_path_layout() { let s1 = make_step("s1", "human:alex", &[]); let path = Path { path: PathIdentity { @@ -1537,13 +1540,13 @@ mod tests { steps: vec![s1], meta: None, }; - let doc = Document::Path(path); - let md = render(&doc, &RenderOptions::default()); + let graph = Graph::from_path(path); + let md = render(&graph, &RenderOptions::default()); assert!(md.contains("## Timeline")); } #[test] - fn test_render_dispatches_graph() { + fn test_render_empty_graph_uses_graph_layout() { let graph = Graph { graph: GraphIdentity { id: "g1".into() }, paths: vec![], @@ -1552,8 +1555,7 @@ mod tests { ..Default::default() }), }; - let doc = Document::Graph(graph); - let md = render(&doc, &RenderOptions::default()); + let md = render(&graph, &RenderOptions::default()); assert!(md.contains("# My Graph")); } diff --git a/crates/toolpath-opencode/src/derive.rs b/crates/toolpath-opencode/src/derive.rs index 56915ce..70c1451 100644 --- a/crates/toolpath-opencode/src/derive.rs +++ b/crates/toolpath-opencode/src/derive.rs @@ -573,7 +573,7 @@ mod tests { use rusqlite::Connection; use std::fs; use tempfile::TempDir; - use toolpath::v1::Document; + use toolpath::v1::Graph; fn fixture(body_sql: &str) -> (TempDir, OpencodeConvo, PathResolver) { let temp = TempDir::new().unwrap(); @@ -665,15 +665,12 @@ mod tests { }, &resolver, ); - let doc = Document::Path(p); + let doc = Graph::from_path(p); let json = doc.to_json().unwrap(); - let parsed = Document::from_json(&json).unwrap(); - if let Document::Path(pp) = parsed { - let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head); - assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry"); - } else { - panic!("expected Path"); - } + let parsed = Graph::from_json(&json).unwrap(); + let pp = parsed.single_path().expect("single-path graph"); + let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head); + assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry"); } #[test] diff --git a/crates/toolpath-pi/Cargo.toml b/crates/toolpath-pi/Cargo.toml index 76d5198..cea5811 100644 --- a/crates/toolpath-pi/Cargo.toml +++ b/crates/toolpath-pi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-pi" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-pi/src/derive.rs b/crates/toolpath-pi/src/derive.rs index b64902a..d60a8be 100644 --- a/crates/toolpath-pi/src/derive.rs +++ b/crates/toolpath-pi/src/derive.rs @@ -8,7 +8,7 @@ use crate::PiConvo; use crate::provider::session_to_view; use crate::reader::PiSession; -use toolpath::v1::{Document, Graph, GraphIdentity, GraphMeta, Path, PathOrRef}; +use toolpath::v1::{Graph, GraphIdentity, GraphMeta, Path, PathOrRef}; use toolpath_convo::DeriveConfig; /// Derive a Toolpath [`Path`] from a single Pi session. @@ -48,16 +48,15 @@ pub fn derive_graph(sessions: &[PiSession], title: Option<&str>, config: &Derive } } -/// Derive a [`Document::Graph`] from all sessions in a project. +/// Derive a [`Graph`] from all sessions in a project. pub fn derive_project( manager: &PiConvo, project: &str, title: Option<&str>, config: &DeriveConfig, -) -> crate::Result { +) -> crate::Result { let sessions = manager.read_all_sessions(project)?; - let graph = derive_graph(&sessions, title, config); - Ok(Document::Graph(graph)) + Ok(derive_graph(&sessions, title, config)) } #[cfg(test)] diff --git a/crates/toolpath-pi/tests/end_to_end.rs b/crates/toolpath-pi/tests/end_to_end.rs index c94310c..c57f5d6 100644 --- a/crates/toolpath-pi/tests/end_to_end.rs +++ b/crates/toolpath-pi/tests/end_to_end.rs @@ -123,9 +123,9 @@ fn test_derive_roundtrip_serde() { let session = manager.read_session(PROJECT_CWD, "demo-session-1").unwrap(); let path = toolpath_pi::derive_path(&session, &DeriveConfig::default()); - let doc = toolpath::v1::Document::Path(path); + let doc = toolpath::v1::Graph::from_path(path); let json = doc.to_json_pretty().unwrap(); - let parsed = toolpath::v1::Document::from_json(&json).unwrap(); + let parsed = toolpath::v1::Graph::from_json(&json).unwrap(); // Compare as structured JSON values — HashMap-based `extra`/`actors` have // non-deterministic key order when re-serialized, so a string compare is flaky. let a: serde_json::Value = serde_json::from_str(&json).unwrap(); diff --git a/crates/toolpath/Cargo.toml b/crates/toolpath/Cargo.toml index a641caa..f1f7869 100644 --- a/crates/toolpath/Cargo.toml +++ b/crates/toolpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath/README.md b/crates/toolpath/README.md index 8b6d93d..9dee28f 100644 --- a/crates/toolpath/README.md +++ b/crates/toolpath/README.md @@ -8,15 +8,16 @@ everything that happens to code — including the stuff git doesn't see." Three objects model this: a **Step** is one atomic change by one actor. A **Path** is a DAG of steps (like a PR) — abandoned branches become -implicit dead ends. A **Graph** collects related paths (like a release). +implicit dead ends. A **Graph** collects related paths (like a release) +and is the single root type of every Toolpath document. ## Overview This crate provides the type system and query API for Toolpath. It contains: -- **Types**: `Document`, `Graph`, `Path`, `Step`, `ArtifactChange`, and all supporting structures +- **Types**: `Graph`, `Path`, `Step`, `ArtifactChange`, and all supporting structures - **Builders**: Convenient constructors and builder methods for constructing documents -- **Serde**: Full serialization/deserialization with `#[serde(untagged)]` document discrimination +- **Serde**: Full serialization/deserialization - **Query**: Graph traversal and filtering operations on step DAGs This is the gravity well of the workspace. All other crates depend on `toolpath`; it depends on nothing except `serde` and `serde_json`. @@ -24,9 +25,7 @@ This is the gravity well of the workspace. All other crates depend on `toolpath` ## Types ```text -Document (enum: Graph | Path | Step) - -Graph +Graph -- the root type of every Toolpath document graph: GraphIdentity { id } paths: Vec -- inline Path or $ref meta?: GraphMeta @@ -90,18 +89,31 @@ let index = query::step_index(&steps); ## Serialization -Documents roundtrip through JSON: +A Toolpath document is a JSON-serialized `Graph` at the root. Single-step or +single-path provenance becomes a single-path graph: ```rust -use toolpath::v1::Document; - -let json_str = r#"{"Step":{"step":{"id":"s1","actor":"human:alex","timestamp":"2026-01-29T10:00:00Z"},"change":{}}}"#; -let doc = Document::from_json(json_str).unwrap(); -let json = doc.to_json_pretty().unwrap(); +use toolpath::v1::{Graph, Path, Step}; + +let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z") + .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new"); +let path = Path { + path: toolpath::v1::PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![step], + meta: None, +}; +let graph = Graph::from_path(path); +let json = graph.to_json_pretty().unwrap(); assert!(json.contains("s1")); -``` -The `Document` enum uses `#[serde(untagged)]` and discriminates by structure: it tries Graph (has `graph` + `paths`), then Path (has `path` + `steps`), then Step (has `step` + `change`). +let parsed = Graph::from_json(&json).unwrap(); +assert_eq!(parsed.single_path().unwrap().path.id, "p1"); +``` ## Part of Toolpath diff --git a/crates/toolpath/src/jsonl.rs b/crates/toolpath/src/jsonl.rs index 94dae15..28a2d9c 100644 --- a/crates/toolpath/src/jsonl.rs +++ b/crates/toolpath/src/jsonl.rs @@ -1,21 +1,22 @@ -//! JSONL streaming format for `Path` documents. +//! JSONL streaming format for single-path Toolpath documents. //! //! A `.path.jsonl` file is a line-oriented sequence of self-describing JSON -//! objects that seals to a canonical [`Path`]. Each line is an externally -//! tagged JSON object: `{"": }`. Writers append one line per -//! logical event (path open, new step, new signature, etc.); readers -//! accumulate these into a single `Path` document. +//! objects that seals to a single-path [`Graph`]. On the wire, each line is +//! an externally tagged JSON object: `{"": }`. Writers append +//! one line per logical event (path open, new step, new signature, etc.); +//! readers accumulate these into the inner [`Path`], then wrap it as a +//! single-path [`Graph`] at the file boundary. //! //! See `docs/RFC-jsonl.md` for the full specification. Round-trip guarantee: //! reading a JSONL file and writing it back produces a JSON document -//! equivalent to what [`Path::to_json`] would produce for the same logical -//! path — signatures computed over canonical JSON remain valid across +//! equivalent to what [`Graph::to_json`] would produce for the same logical +//! graph — signatures computed over canonical JSON remain valid across //! conversions. //! //! # Reading //! //! ``` -//! use toolpath::v1::Path; +//! use toolpath::v1::Graph; //! //! let jsonl = concat!( //! r#"{"PathOpen":{"version":"1","id":"pr-42","base":{"uri":"github:org/repo","ref":"abc"}}}"#, "\n", @@ -23,7 +24,8 @@ //! r#"{"Head":{"step_id":"s1"}}"#, "\n", //! r#"{"PathClose":{}}"#, "\n", //! ); -//! let path = Path::from_jsonl_str(jsonl).unwrap(); +//! let graph = Graph::from_jsonl_str(jsonl).unwrap(); +//! let path = graph.single_path().expect("single-path graph"); //! assert_eq!(path.path.id, "pr-42"); //! assert_eq!(path.path.head, "s1"); //! assert_eq!(path.steps.len(), 1); @@ -32,7 +34,7 @@ //! # Writing //! //! ``` -//! use toolpath::v1::{Base, Path, PathIdentity, Step}; +//! use toolpath::v1::{Base, Graph, Path, PathIdentity, Step}; //! //! let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z") //! .with_raw_change("f.rs", "@@ -0,0 +1 @@\n+hi"); @@ -46,13 +48,15 @@ //! steps: vec![step], //! meta: None, //! }; -//! let jsonl = path.to_jsonl_string().unwrap(); +//! let graph = Graph::from_path(path); +//! let jsonl = graph.to_jsonl_string().unwrap(); //! let first_line = jsonl.lines().next().unwrap(); //! assert!(first_line.starts_with(r#"{"PathOpen":"#)); //! ``` use crate::types::{ - ActorDefinition, Base, Path, PathIdentity, PathMeta, Ref, Signature, Step, StepMeta, + ActorDefinition, Base, Graph, Path, PathIdentity, PathMeta, PathOrRef, Ref, Signature, Step, + StepMeta, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -202,6 +206,9 @@ pub enum JsonlError { NoSteps, /// A line appeared after `PathClose`. AfterClose { line_num: usize }, + /// JSONL only encodes single-path graphs; the source graph held zero or + /// more than one inline path. + NotSinglePathGraph { path_count: usize }, } impl fmt::Display for JsonlError { @@ -242,6 +249,10 @@ impl fmt::Display for JsonlError { JsonlError::AfterClose { line_num } => { write!(f, "line {line_num}: unexpected line after PathClose") } + JsonlError::NotSinglePathGraph { path_count } => write!( + f, + "JSONL only encodes single-path graphs (got {path_count} paths)" + ), } } } @@ -278,8 +289,11 @@ enum ParsedLine { } fn parse_line(line: &str, line_num: usize) -> Result { - let value: serde_json::Value = serde_json::from_str(line) - .map_err(|source| JsonlError::MalformedJson { line_num, source })?; + let value: serde_json::Value = + serde_json::from_str(line).map_err(|source| JsonlError::MalformedJson { + line_num, + source, + })?; let obj = value .as_object() .ok_or(JsonlError::NotAnObject { line_num })?; @@ -423,6 +437,40 @@ impl Path { } } +impl Graph { + /// Read a JSONL toolpath document from any buffered reader. The stream is + /// parsed as a single inline [`Path`] and wrapped in a single-path + /// [`Graph`]. + pub fn from_jsonl_reader(reader: R) -> Result { + let path = Path::from_jsonl_reader(reader)?; + Ok(Graph::from_path(path)) + } + + /// Read a JSONL toolpath document from a string. + pub fn from_jsonl_str(s: &str) -> Result { + Self::from_jsonl_reader(std::io::Cursor::new(s)) + } + + /// Write the graph as JSONL. Errors with [`JsonlError::NotSinglePathGraph`] + /// if the graph does not hold exactly one inline path — JSONL has no + /// representation for multi-path graphs or `$ref` entries. + pub fn to_jsonl_writer(&self, w: &mut W) -> Result<(), JsonlError> { + match self.paths.as_slice() { + [PathOrRef::Path(p)] => p.to_jsonl_writer(w), + other => Err(JsonlError::NotSinglePathGraph { + path_count: other.len(), + }), + } + } + + /// Write the graph as JSONL into a string. + pub fn to_jsonl_string(&self) -> Result { + let mut buf: Vec = Vec::new(); + self.to_jsonl_writer(&mut buf)?; + Ok(String::from_utf8(buf).expect("jsonl writer emits utf-8")) + } +} + fn apply_signature( path_meta: &mut PathMeta, steps: &mut [Step], @@ -435,14 +483,13 @@ fn apply_signature( return Ok(()); } if let Some(step_id) = body.target.strip_prefix("step:") { - let idx = - step_idx - .get(step_id) - .copied() - .ok_or_else(|| JsonlError::OrphanStepSignature { - line_num, - step_id: step_id.to_string(), - })?; + let idx = step_idx + .get(step_id) + .copied() + .ok_or_else(|| JsonlError::OrphanStepSignature { + line_num, + step_id: step_id.to_string(), + })?; let step = &mut steps[idx]; let meta = step.meta.get_or_insert_with(StepMeta::default); meta.signatures.push(body.signature); @@ -649,13 +696,15 @@ fn path_meta_for_open(m: &PathMeta) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::types::{ArtifactChange, Document, Ref}; + use crate::types::{ArtifactChange, Ref}; use serde_json::json; use std::collections::HashMap; fn make_step(id: &str, parent: Option<&str>) -> Step { - let mut s = Step::new(id, "human:alex", "2026-01-01T00:00:00Z") - .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-a\n+b"); + let mut s = Step::new(id, "human:alex", "2026-01-01T00:00:00Z").with_raw_change( + "src/main.rs", + "@@ -1 +1 @@\n-a\n+b", + ); if let Some(p) = parent { s = s.with_parent(p); } @@ -663,7 +712,7 @@ mod tests { } fn canonical_json(path: &Path) -> serde_json::Value { - serde_json::to_value(Document::Path(path.clone())).unwrap() + serde_json::to_value(path).unwrap() } // ── Line kind serde ──────────────────────────────────────────────────── diff --git a/crates/toolpath/src/lib.rs b/crates/toolpath/src/lib.rs index 0a42688..5ddec38 100644 --- a/crates/toolpath/src/lib.rs +++ b/crates/toolpath/src/lib.rs @@ -14,10 +14,9 @@ pub mod v1 { //! //! The top-level types you construct, serialize, and deserialize: //! - //! - [`Document`] — an enum that can hold any of the three document kinds - //! - [`Step`] — a single atomic change + //! - [`Graph`] — a collection of paths; the single root type of every Toolpath document //! - [`Path`] — a sequence of steps (e.g. a PR) - //! - [`Graph`] — a collection of paths (e.g. a release) + //! - [`Step`] — a single atomic change //! //! # Change representation //! @@ -134,8 +133,8 @@ pub mod v1 { } pub use crate::types::{ - ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, - Key, Path, PathIdentity, PathMeta, PathOrRef, PathRef, Ref, Signature, Step, StepIdentity, + ActorDefinition, ArtifactChange, Base, Graph, GraphIdentity, GraphMeta, Identity, Key, + Path, PathIdentity, PathMeta, PathOrRef, PathRef, Ref, Signature, Step, StepIdentity, StepMeta, StructuralChange, VcsSource, }; } diff --git a/crates/toolpath/src/types.rs b/crates/toolpath/src/types.rs index b5e7bf7..d6f3612 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -1,55 +1,15 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -/// A Toolpath document — either a [`Step`], [`Path`], or [`Graph`]. -/// -/// `Document` is externally tagged: the top-level JSON object has a single key -/// (`"Step"`, `"Path"`, or `"Graph"`) whose value is the document content. -/// This makes the document type unambiguous without inspecting the inner fields. -/// -/// # Minimal JSON for each variant -/// -/// **Step** — the simplest document: -/// ```json -/// { -/// "Step": { -/// "step": { "id": "s1", "actor": "human:alex", "timestamp": "2026-01-29T10:00:00Z" }, -/// "change": { "src/main.rs": { "raw": "@@ …" } } -/// } -/// } -/// ``` -/// -/// **Path** — a sequence of steps: -/// ```json -/// { -/// "Path": { -/// "path": { "id": "p1", "head": "s2" }, -/// "steps": [ … ] -/// } -/// } -/// ``` -/// -/// **Graph** — a collection of paths: -/// ```json -/// { -/// "Graph": { -/// "graph": { "id": "g1" }, -/// "paths": [ … ] -/// } -/// } -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Document { - Graph(Graph), - Path(Path), - Step(Step), -} - // ============================================================================ -// Graph +// Graph — the root of every Toolpath document // ============================================================================ -/// A collection of related paths — for example, all the PRs in a release. +/// A Toolpath document — a collection of related paths. +/// +/// `Graph` is the single root type of the format. Every `.path.json` file +/// deserializes to a `Graph`; a "single PR" or "single conversation" is just +/// a Graph that happens to contain one path. /// /// Each entry in `paths` is either an inline [`Path`] or a [`PathRef`] /// pointing to an external document (via `$ref`). @@ -378,30 +338,63 @@ pub struct Signature { // Convenience methods // ============================================================================ -impl Document { - /// Parse a Toolpath document from JSON +impl Graph { + /// Create a new graph with the given ID + pub fn new(id: impl Into) -> Self { + Self { + graph: GraphIdentity { id: id.into() }, + paths: Vec::new(), + meta: None, + } + } + + /// Create a single-path graph wrapping `path`. The graph's id mirrors the + /// path's id so an unwrapped path-shaped derivation has a natural lift to + /// the top-level format. + pub fn from_path(path: Path) -> Self { + Self { + graph: GraphIdentity { + id: path.path.id.clone(), + }, + paths: vec![PathOrRef::Path(Box::new(path))], + meta: None, + } + } + + /// Parse a Toolpath document from JSON. pub fn from_json(json: &str) -> Result { serde_json::from_str(json) } - /// Serialize to JSON + /// Serialize to JSON. pub fn to_json(&self) -> Result { serde_json::to_string(self) } - /// Serialize to pretty-printed JSON + /// Serialize to pretty-printed JSON. pub fn to_json_pretty(&self) -> Result { serde_json::to_string_pretty(self) } -} -impl Graph { - /// Create a new graph with the given ID - pub fn new(id: impl Into) -> Self { - Self { - graph: GraphIdentity { id: id.into() }, - paths: Vec::new(), - meta: None, + /// If this graph wraps exactly one inline path, return it. + pub fn single_path(&self) -> Option<&Path> { + if self.paths.len() != 1 { + return None; + } + match &self.paths[0] { + PathOrRef::Path(p) => Some(p), + PathOrRef::Ref(_) => None, + } + } + + /// If this graph wraps exactly one inline path, take it by consuming the graph. + pub fn into_single_path(self) -> Option { + if self.paths.len() != 1 { + return None; + } + match self.paths.into_iter().next().unwrap() { + PathOrRef::Path(p) => Some(*p), + PathOrRef::Ref(_) => None, } } } @@ -547,24 +540,19 @@ mod tests { assert_eq!(toolpath_base.ref_str, None); } - // ── Document serialization ───────────────────────────────────────── + // ── Graph serialization ──────────────────────────────────────────── #[test] - fn test_document_step_roundtrip() { - let step = - Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_raw_change("f.rs", "@@"); - let doc = Document::Step(step); - let json = doc.to_json().unwrap(); - assert!(json.contains("\"Step\"")); - let parsed = Document::from_json(&json).unwrap(); - match parsed { - Document::Step(s) => assert_eq!(s.step.id, "s1"), - _ => panic!("Expected Step"), - } + fn test_graph_roundtrip_empty() { + let graph = Graph::new("g1"); + let json = graph.to_json().unwrap(); + let parsed = Graph::from_json(&json).unwrap(); + assert_eq!(parsed.graph.id, "g1"); + assert!(parsed.paths.is_empty()); } #[test] - fn test_document_path_roundtrip() { + fn test_graph_from_path_wraps_single_path() { let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z").with_raw_change("f.rs", "@@"); let path = Path { @@ -577,45 +565,54 @@ mod tests { steps: vec![step], meta: None, }; - let doc = Document::Path(path); - let json = doc.to_json().unwrap(); - assert!(json.contains("\"Path\"")); - let parsed = Document::from_json(&json).unwrap(); - match parsed { - Document::Path(p) => { - assert_eq!(p.path.id, "p1"); - assert_eq!(p.steps.len(), 1); - } - _ => panic!("Expected Path"), - } + let graph = Graph::from_path(path); + assert_eq!(graph.graph.id, "p1"); + let json = graph.to_json().unwrap(); + let parsed = Graph::from_json(&json).unwrap(); + let inline = parsed.single_path().unwrap(); + assert_eq!(inline.path.id, "p1"); + assert_eq!(inline.steps.len(), 1); } #[test] - fn test_document_graph_roundtrip() { + fn test_graph_to_json_pretty() { let graph = Graph::new("g1"); - let doc = Document::Graph(graph); - let json = doc.to_json().unwrap(); - assert!(json.contains("\"Graph\"")); - let parsed = Document::from_json(&json).unwrap(); - match parsed { - Document::Graph(g) => assert_eq!(g.graph.id, "g1"), - _ => panic!("Expected Graph"), - } + let json = graph.to_json_pretty().unwrap(); + assert!(json.contains('\n')); + assert!(json.contains("\"g1\"")); } #[test] - fn test_document_to_json_pretty() { - let step = Step::new("s1", "human:alex", "2026-01-01T00:00:00Z"); - let doc = Document::Step(step); - let json = doc.to_json_pretty().unwrap(); - assert!(json.contains('\n')); // pretty-printed has newlines - assert!(json.contains("\"Step\"")); + fn test_graph_from_json_invalid() { + let result = Graph::from_json("not json"); + assert!(result.is_err()); } #[test] - fn test_document_from_json_invalid() { - let result = Document::from_json("not json"); - assert!(result.is_err()); + fn test_graph_single_path_none_for_multi() { + let p1 = Path::new("p1", None, "s1"); + let p2 = Path::new("p2", None, "s2"); + let graph = Graph { + graph: GraphIdentity { id: "g".into() }, + paths: vec![ + PathOrRef::Path(Box::new(p1)), + PathOrRef::Path(Box::new(p2)), + ], + meta: None, + }; + assert!(graph.single_path().is_none()); + } + + #[test] + fn test_graph_single_path_none_for_ref() { + let graph = Graph { + graph: GraphIdentity { id: "g".into() }, + paths: vec![PathOrRef::Ref(PathRef { + ref_url: "https://example.com/p.json".into(), + })], + meta: None, + }; + assert!(graph.single_path().is_none()); } // ── Graph::new ───────────────────────────────────────────────────── diff --git a/docs/RFC-jsonl.md b/docs/RFC-jsonl.md index 52c2ada..f7c9ac7 100644 --- a/docs/RFC-jsonl.md +++ b/docs/RFC-jsonl.md @@ -57,9 +57,9 @@ document. 1. **Streaming individual steps.** Each step is atomic per line. Partial step construction (e.g., emitting a step identity now and its diff later) is out of scope. -2. **Graph-scoped streams.** A JSONL file contains exactly one `Path`. - Graphs compose streaming and non-streaming path files via existing - `$ref` semantics. +2. **Multi-path streams.** A JSONL file encodes exactly one inline `Path`, + which is wrapped at the file boundary as a single-path `Graph`. Multi-path + graphs compose JSONL files via existing `$ref` semantics. 3. **Mid-file resync or corruption recovery.** Readers always start from line 1. 4. **Transport semantics.** The format is equally usable as a storage @@ -68,35 +68,31 @@ document. ## Scope -v1 covers the `Path` document type. `Step` documents are already a single -JSON blob and do not benefit from streaming. `Graph` documents are a -container of path references; a streaming graph is a graph that references -streaming path files. +v1 covers a single inline `Path` streamed across many JSON lines. The format +is a single-path graph at the file boundary: when readers seal the stream, +they wrap the resulting `Path` as a single-path `Graph` (a `Graph` whose +`paths` array holds exactly one inline path). Multi-path graphs are not +streamable — they live as canonical `.path.json` files and reference +streaming siblings via `$ref`. ## File Extensions -Toolpath `Path` documents use a two-part extension that encodes both the -document type and the serialization format: +Toolpath documents use a two-part extension that encodes both the +serialization strategy and the streaming variant: | Extension | Format | Description | | --------- | ------ | ----------- | -| `.path.json` | Canonical JSON | A complete `Path` document as a single `{"Path": {...}}` JSON blob. This is the "whole" format — the entire path is buffered and serialized at once. | -| `.path.jsonl` | Streaming JSONL | A `Path` document expressed as a sequence of self-describing JSON lines, one per line. This is the streaming format defined by this RFC. | +| `.path.json` | Canonical JSON | A `Graph` document serialized as a single JSON blob. Multi-path graphs require this format. | +| `.path.jsonl` | Streaming JSONL | A single inline `Path` expressed as a sequence of self-describing JSON lines, sealed as a single-path `Graph` at the file boundary. | -Both extensions identify `Path` documents. The suffix (`.json` vs `.jsonl`) -distinguishes the serialization strategy. Tools that accept `.path.jsonl` -input read it into the same in-memory representation as a `.path.json` -file. - -`Step` and `Graph` documents retain their existing conventions (`.json` -extension, `{"Step": ...}` / `{"Graph": ...}` envelope). Only `Path` has a -streaming peer format. +Tools that accept `.path.jsonl` input read it into the same in-memory `Graph` +representation as a `.path.json` file. Graph `$ref` entries MUST point to sealed `.path.json` files, not to `.path.jsonl` streams. A `$ref` is a promise that the target is a complete, valid document; a streaming file may be incomplete or mid-write. Tools that consume `.path.jsonl` files should convert them to -`.path.json` before incorporating them into a graph. +`.path.json` before incorporating them into a multi-path graph. ## File Structure @@ -110,7 +106,7 @@ Tools that consume `.path.jsonl` files should convert them to | Line format | One JSON object per line | No blank lines, no comments, no trailing commas. Each line is a single -externally tagged JSON object, mirroring the canonical `Document` envelope: +externally tagged JSON object: ``` {"": } diff --git a/examples/graph-01-release.json b/examples/graph-01-release.json index 13c1c23..654ebd7 100644 --- a/examples/graph-01-release.json +++ b/examples/graph-01-release.json @@ -1,180 +1,207 @@ { - "Graph": { - "graph": { - "id": "graph-release-v2.0" - }, - - "paths": [ - { - "path": { - "id": "path-pr-42", - "base": { - "uri": "github:myorg/myrepo", - "ref": "main", - "commit": "abc123def456" - }, - "head": "step-003" + "graph": { + "id": "graph-release-v2.0" + }, + "paths": [ + { + "path": { + "id": "path-pr-42", + "base": { + "uri": "github:myorg/myrepo", + "ref": "main", + "commit": "abc123def456" }, - "steps": [ - { - "step": { - "id": "step-001", - "actor": "agent:claude-code", - "timestamp": "2026-01-29T10:00:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,25 @@\n+pub struct ValidationError {...}" - } - }, - "meta": { - "intent": "Add email validation with custom error type" - } + "head": "step-003" + }, + "steps": [ + { + "step": { + "id": "step-001", + "actor": "agent:claude-code", + "timestamp": "2026-01-29T10:00:00Z" }, - { - "step": { - "id": "step-002", - "parents": ["step-001"], - "actor": "tool:rustfmt", - "timestamp": "2026-01-29T10:00:30Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -15,2 +15,5 @@\n-pub fn validate..." - } - }, - "meta": { - "intent": "Auto-format" + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,25 @@\n+pub struct ValidationError {...}" } }, - { - "step": { - "id": "step-003", - "parents": ["step-002"], - "actor": "human:alex", - "timestamp": "2026-01-29T10:15:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -20,2 +20,2 @@\n-message: \"must contain @\"..." - } - }, - "meta": { - "intent": "Improve error messages for better UX" - } + "meta": { + "intent": "Add email validation with custom error type" } - ], - "meta": { - "title": "Add email validation", - "source": "github:myorg/myrepo/pull/42" - } - }, - { - "path": { - "id": "path-pr-43", - "base": { - "uri": "github:myorg/myrepo", - "ref": "main", - "commit": "def456789abc" - }, - "head": "step-002" }, - "steps": [ - { - "step": { - "id": "step-001", - "actor": "human:bob", - "timestamp": "2026-01-28T14:00:00Z" - }, - "change": { - "src/auth/session.rs": { - "raw": "@@ -45,3 +45,8 @@\n+fn refresh_token(...)" - } - }, - "meta": { - "intent": "Add session refresh to prevent timeouts" + { + "step": { + "id": "step-002", + "parents": [ + "step-001" + ], + "actor": "tool:rustfmt", + "timestamp": "2026-01-29T10:00:30Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -15,2 +15,5 @@\n-pub fn validate..." } }, - { - "step": { - "id": "step-002", - "parents": ["step-001"], - "actor": "agent:claude-code", - "timestamp": "2026-01-28T14:30:00Z" - }, - "change": { - "src/auth/session.rs": { - "raw": "@@ -50,5 +50,15 @@\n+// Handle edge cases..." - } - }, - "meta": { - "intent": "Add edge case handling for expired refresh tokens" + "meta": { + "intent": "Auto-format" + } + }, + { + "step": { + "id": "step-003", + "parents": [ + "step-002" + ], + "actor": "human:alex", + "timestamp": "2026-01-29T10:15:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -20,2 +20,2 @@\n-message: \"must contain @\"..." } + }, + "meta": { + "intent": "Improve error messages for better UX" } - ], - "meta": { - "title": "Fix session timeout bug", - "source": "github:myorg/myrepo/pull/43" } - }, - { "$ref": "https://archive.example.com/toolpath/path-pr-44.json" }, - { "$ref": "toolpath://internal/path-pr-45" } - ], - - "meta": { - "title": "Release v2.0", - "refs": [ - {"rel": "milestone", "href": "issue://github/myorg/myrepo/milestone/5"}, - {"rel": "changelog", "href": "doc://CHANGELOG.md#v2.0"} ], - "actors": { - "human:alex": { - "name": "Alex Kesling", - "identities": [ - {"system": "github", "id": "akesling"}, - {"system": "email", "id": "alex@empathic.dev"} - ] - }, - "human:bob": { - "name": "Bob Developer", - "identities": [ - {"system": "github", "id": "bobdev"} - ] + "meta": { + "title": "Add email validation", + "source": "github:myorg/myrepo/pull/42" + } + }, + { + "path": { + "id": "path-pr-43", + "base": { + "uri": "github:myorg/myrepo", + "ref": "main", + "commit": "def456789abc" }, - "human:release-manager": { - "name": "Release Manager", - "identities": [ - {"system": "github", "id": "releasebot"} - ], - "keys": [ - { - "type": "gpg", - "fingerprint": "RELEASE1234567890ABCD", - "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/..." + "head": "step-002" + }, + "steps": [ + { + "step": { + "id": "step-001", + "actor": "human:bob", + "timestamp": "2026-01-28T14:00:00Z" + }, + "change": { + "src/auth/session.rs": { + "raw": "@@ -45,3 +45,8 @@\n+fn refresh_token(...)" } - ] - }, - "agent:claude-code": { - "name": "Claude Code", - "provider": "anthropic", - "model": "claude-sonnet-4-20250514" + }, + "meta": { + "intent": "Add session refresh to prevent timeouts" + } }, - "tool:rustfmt": { - "name": "rustfmt", - "identities": [ - {"system": "crates.io", "id": "rustfmt-nightly/1.7.0"} - ] - } - }, - "signatures": [ { - "signer": "human:release-manager", - "key": "gpg:RELEASE1234567890ABCD", - "scope": "release", - "timestamp": "2026-01-30T09:00:00Z", - "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(release signature)...\n-----END PGP SIGNATURE-----" + "step": { + "id": "step-002", + "parents": [ + "step-001" + ], + "actor": "agent:claude-code", + "timestamp": "2026-01-28T14:30:00Z" + }, + "change": { + "src/auth/session.rs": { + "raw": "@@ -50,5 +50,15 @@\n+// Handle edge cases..." + } + }, + "meta": { + "intent": "Add edge case handling for expired refresh tokens" + } } - ] + ], + "meta": { + "title": "Fix session timeout bug", + "source": "github:myorg/myrepo/pull/43" + } + }, + { + "$ref": "https://archive.example.com/toolpath/path-pr-44.json" + }, + { + "$ref": "toolpath://internal/path-pr-45" } + ], + "meta": { + "title": "Release v2.0", + "refs": [ + { + "rel": "milestone", + "href": "issue://github/myorg/myrepo/milestone/5" + }, + { + "rel": "changelog", + "href": "doc://CHANGELOG.md#v2.0" + } + ], + "actors": { + "human:alex": { + "name": "Alex Kesling", + "identities": [ + { + "system": "github", + "id": "akesling" + }, + { + "system": "email", + "id": "alex@empathic.dev" + } + ] + }, + "human:bob": { + "name": "Bob Developer", + "identities": [ + { + "system": "github", + "id": "bobdev" + } + ] + }, + "human:release-manager": { + "name": "Release Manager", + "identities": [ + { + "system": "github", + "id": "releasebot" + } + ], + "keys": [ + { + "type": "gpg", + "fingerprint": "RELEASE1234567890ABCD", + "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/..." + } + ] + }, + "agent:claude-code": { + "name": "Claude Code", + "provider": "anthropic", + "model": "claude-sonnet-4-20250514" + }, + "tool:rustfmt": { + "name": "rustfmt", + "identities": [ + { + "system": "crates.io", + "id": "rustfmt-nightly/1.7.0" + } + ] + } + }, + "signatures": [ + { + "signer": "human:release-manager", + "key": "gpg:RELEASE1234567890ABCD", + "scope": "release", + "timestamp": "2026-01-30T09:00:00Z", + "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(release signature)...\n-----END PGP SIGNATURE-----" + } + ] } } diff --git a/examples/path-01-pr.path.json b/examples/path-01-pr.path.json index 66fcdd9..073e635 100644 --- a/examples/path-01-pr.path.json +++ b/examples/path-01-pr.path.json @@ -1,100 +1,114 @@ { - "Path": { - "path": { - "id": "path-pr-42", - "base": { - "uri": "github:myorg/myrepo", - "ref": "main", - "commit": "abc123def456789" - }, - "head": "step-004" - }, - - "steps": [ - { - "step": { - "id": "step-001", - "actor": "human:alex", - "timestamp": "2026-01-29T10:00:00Z" + "graph": { + "id": "graph-path-pr-42" + }, + "paths": [ + { + "path": { + "id": "path-pr-42", + "base": { + "uri": "github:myorg/myrepo", + "ref": "main", + "commit": "abc123def456789" }, - "change": { - "src/main.rs": { - "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" - } - } + "head": "step-004" }, - { - "step": { - "id": "step-002a", - "parents": ["step-001"], - "actor": "agent:claude-code/session-abc123", - "timestamp": "2026-01-29T10:03:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,15 @@\n+use regex::Regex;..." + "steps": [ + { + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-01-29T10:00:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" + } } }, - "meta": { - "intent": "Regex-based validation (abandoned)" - } - }, - { - "step": { - "id": "step-002", - "parents": ["step-001"], - "actor": "agent:claude-code/session-abc123", - "timestamp": "2026-01-29T10:05:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,25 @@\n+pub struct ValidationError..." + { + "step": { + "id": "step-002a", + "parents": [ + "step-001" + ], + "actor": "agent:claude-code/session-abc123", + "timestamp": "2026-01-29T10:03:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,15 @@\n+use regex::Regex;..." + } + }, + "meta": { + "intent": "Regex-based validation (abandoned)" } }, - "meta": { - "intent": "Add email validation with custom error type" - } - }, - { - "step": { - "id": "step-003", - "parents": ["step-002"], - "actor": "tool:rustfmt/1.7.0", - "timestamp": "2026-01-29T10:05:30Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -15,4 +15,8 @@\n-pub fn validate_email..." + { + "step": { + "id": "step-002", + "parents": [ + "step-001" + ], + "actor": "agent:claude-code/session-abc123", + "timestamp": "2026-01-29T10:05:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,25 @@\n+pub struct ValidationError..." + } + }, + "meta": { + "intent": "Add email validation with custom error type" } }, - "meta": { - "intent": "Auto-format" - } - }, - { - "step": { - "id": "step-004", - "parents": ["step-003"], - "actor": "human:alex", - "timestamp": "2026-01-29T10:15:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -20,2 +20,2 @@\n-message: \"must contain @\"..." + { + "step": { + "id": "step-003", + "parents": [ + "step-002" + ], + "actor": "tool:rustfmt/1.7.0", + "timestamp": "2026-01-29T10:05:30Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -15,4 +15,8 @@\n-pub fn validate_email..." + } + }, + "meta": { + "intent": "Auto-format" } }, - "meta": { - "intent": "Refine error messages" + { + "step": { + "id": "step-004", + "parents": [ + "step-003" + ], + "actor": "human:alex", + "timestamp": "2026-01-29T10:15:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -20,2 +20,2 @@\n-message: \"must contain @\"..." + } + }, + "meta": { + "intent": "Refine error messages" + } } + ], + "meta": { + "title": "Add email validation", + "source": "github:myorg/myrepo/pull/42", + "refs": [ + { + "rel": "fixes", + "href": "issue://github/myorg/myrepo/issues/42" + } + ] } - ], - - "meta": { - "title": "Add email validation", - "source": "github:myorg/myrepo/pull/42", - "refs": [ - {"rel": "fixes", "href": "issue://github/myorg/myrepo/issues/42"} - ] } - } + ] } diff --git a/examples/path-02-local-session.path.json b/examples/path-02-local-session.path.json index 8cd9ce9..75ed881 100644 --- a/examples/path-02-local-session.path.json +++ b/examples/path-02-local-session.path.json @@ -1,82 +1,101 @@ { - "Path": { - "path": { - "id": "path-session-xyz", - "base": { - "uri": "file:///home/alex/projects/myrepo", - "commit": "def789abc123456" - }, - "head": "step-003" - }, - - "steps": [ - { - "step": { - "id": "step-001", - "actor": "agent:claude-code/session-xyz", - "timestamp": "2026-01-29T14:00:00Z" + "graph": { + "id": "graph-path-session-xyz" + }, + "paths": [ + { + "path": { + "id": "path-session-xyz", + "base": { + "uri": "file:///home/alex/projects/myrepo", + "commit": "def789abc123456" }, - "change": { - "src/lib.rs": { - "raw": "@@ -1,3 +1,10 @@\n+/// Configuration for the service\n+pub struct Config {\n+ pub port: u16,\n+ pub host: String,\n+}\n", - "structural": { - "type": "rust.add_items", - "items": [ - {"kind": "struct", "name": "Config"} - ] + "head": "step-003" + }, + "steps": [ + { + "step": { + "id": "step-001", + "actor": "agent:claude-code/session-xyz", + "timestamp": "2026-01-29T14:00:00Z" + }, + "change": { + "src/lib.rs": { + "raw": "@@ -1,3 +1,10 @@\n+/// Configuration for the service\n+pub struct Config {\n+ pub port: u16,\n+ pub host: String,\n+}\n", + "structural": { + "type": "rust.add_items", + "items": [ + { + "kind": "struct", + "name": "Config" + } + ] + } } + }, + "meta": { + "intent": "Add Config struct for service configuration", + "refs": [ + { + "rel": "requested_by", + "href": "agent://claude-code/session-xyz/turn/1" + } + ] } }, - "meta": { - "intent": "Add Config struct for service configuration", - "refs": [ - {"rel": "requested_by", "href": "agent://claude-code/session-xyz/turn/1"} - ] - } - }, - { - "step": { - "id": "step-002", - "parents": ["step-001"], - "actor": "agent:claude-code/session-xyz", - "timestamp": "2026-01-29T14:02:00Z" - }, - "change": { - "src/lib.rs": { - "raw": "@@ -8,0 +9,15 @@\n+impl Config {\n+ pub fn from_env() -> Self {\n+ Self {\n+ port: std::env::var(\"PORT\")\n+ .unwrap_or_else(|_| \"8080\".to_string())\n+ .parse()\n+ .expect(\"PORT must be a number\"),\n+ host: std::env::var(\"HOST\")\n+ .unwrap_or_else(|_| \"localhost\".to_string()),\n+ }\n+ }\n+}\n", - "structural": { - "type": "rust.add_items", - "items": [ - {"kind": "impl", "for": "Config", "methods": ["from_env"]} - ] + { + "step": { + "id": "step-002", + "parents": [ + "step-001" + ], + "actor": "agent:claude-code/session-xyz", + "timestamp": "2026-01-29T14:02:00Z" + }, + "change": { + "src/lib.rs": { + "raw": "@@ -8,0 +9,15 @@\n+impl Config {\n+ pub fn from_env() -> Self {\n+ Self {\n+ port: std::env::var(\"PORT\")\n+ .unwrap_or_else(|_| \"8080\".to_string())\n+ .parse()\n+ .expect(\"PORT must be a number\"),\n+ host: std::env::var(\"HOST\")\n+ .unwrap_or_else(|_| \"localhost\".to_string()),\n+ }\n+ }\n+}\n", + "structural": { + "type": "rust.add_items", + "items": [ + { + "kind": "impl", + "for": "Config", + "methods": [ + "from_env" + ] + } + ] + } } + }, + "meta": { + "intent": "Add from_env constructor for Config" } }, - "meta": { - "intent": "Add from_env constructor for Config" - } - }, - { - "step": { - "id": "step-003", - "parents": ["step-002"], - "actor": "tool:rustfmt/1.7.0", - "timestamp": "2026-01-29T14:02:05Z" - }, - "change": { - "src/lib.rs": { - "raw": "@@ -12,4 +12,5 @@\n- port: std::env::var(\"PORT\").unwrap_or_else(|_| \"8080\".to_string()).parse().expect(\"PORT must be a number\"),\n+ port: std::env::var(\"PORT\")\n+ .unwrap_or_else(|_| \"8080\".to_string())\n+ .parse()\n+ .expect(\"PORT must be a number\")," + { + "step": { + "id": "step-003", + "parents": [ + "step-002" + ], + "actor": "tool:rustfmt/1.7.0", + "timestamp": "2026-01-29T14:02:05Z" + }, + "change": { + "src/lib.rs": { + "raw": "@@ -12,4 +12,5 @@\n- port: std::env::var(\"PORT\").unwrap_or_else(|_| \"8080\".to_string()).parse().expect(\"PORT must be a number\"),\n+ port: std::env::var(\"PORT\")\n+ .unwrap_or_else(|_| \"8080\".to_string())\n+ .parse()\n+ .expect(\"PORT must be a number\")," + } + }, + "meta": { + "intent": "Auto-format" } - }, - "meta": { - "intent": "Auto-format" } + ], + "meta": { + "title": "Claude Code session: Add service config", + "source": "agent://claude-code/session-xyz" } - ], - - "meta": { - "title": "Claude Code session: Add service config", - "source": "agent://claude-code/session-xyz" } - } + ] } diff --git a/examples/path-03-signed-pr.path.json b/examples/path-03-signed-pr.path.json index 61cb9a9..5cabc34 100644 --- a/examples/path-03-signed-pr.path.json +++ b/examples/path-03-signed-pr.path.json @@ -1,142 +1,174 @@ { - "Path": { - "path": { - "id": "path-pr-42", - "base": { - "uri": "github:myorg/myrepo", - "ref": "main", - "commit": "abc123def456789" - }, - "head": "step-003" - }, - - "steps": [ - { - "step": { - "id": "step-001", - "actor": "agent:claude-code", - "timestamp": "2026-01-29T10:00:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,25 @@\n+pub struct ValidationError {...}" - } - }, - "meta": { - "intent": "Add email validation with custom error type" - } - }, - { - "step": { - "id": "step-002", - "parents": ["step-001"], - "actor": "tool:rustfmt", - "timestamp": "2026-01-29T10:00:30Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -15,2 +15,5 @@\n-pub fn validate_email..." - } + "graph": { + "id": "graph-path-pr-42" + }, + "paths": [ + { + "path": { + "id": "path-pr-42", + "base": { + "uri": "github:myorg/myrepo", + "ref": "main", + "commit": "abc123def456789" }, - "meta": { - "intent": "Auto-format" - } + "head": "step-003" }, - { - "step": { - "id": "step-003", - "parents": ["step-002"], - "actor": "human:alex", - "timestamp": "2026-01-29T10:15:00Z" - }, - "change": { - "src/auth/validator.rs": { - "raw": "@@ -20,2 +20,2 @@\n-message: \"must contain @\"..." + "steps": [ + { + "step": { + "id": "step-001", + "actor": "agent:claude-code", + "timestamp": "2026-01-29T10:00:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,25 @@\n+pub struct ValidationError {...}" + } + }, + "meta": { + "intent": "Add email validation with custom error type" } }, - "meta": { - "intent": "Improve error messages for better UX" - } - } - ], - - "meta": { - "title": "Add email validation", - "source": "github:myorg/myrepo/pull/42", - "refs": [ - {"rel": "fixes", "href": "issue://github/myorg/myrepo/issues/42"} - ], - - "actors": { - "human:alex": { - "name": "Alex Kesling", - "identities": [ - {"system": "github", "id": "akesling"}, - {"system": "email", "id": "alex@empathic.dev"}, - {"system": "orcid", "id": "0000-0001-2345-6789"} - ], - "keys": [ - { - "type": "gpg", - "fingerprint": "ABCD 1234 5678 90EF GHIJ KLMN OPQR STUV WXYZ", - "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/..." + { + "step": { + "id": "step-002", + "parents": [ + "step-001" + ], + "actor": "tool:rustfmt", + "timestamp": "2026-01-29T10:00:30Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -15,2 +15,5 @@\n-pub fn validate_email..." } - ] + }, + "meta": { + "intent": "Auto-format" + } }, - "human:bob": { - "name": "Bob Reviewer", - "identities": [ - {"system": "github", "id": "bobreviewer"}, - {"system": "email", "id": "bob@example.com"} - ], - "keys": [ - { - "type": "gpg", - "fingerprint": "WXYZ 9876 5432 10FE DCBA", - "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/..." + { + "step": { + "id": "step-003", + "parents": [ + "step-002" + ], + "actor": "human:alex", + "timestamp": "2026-01-29T10:15:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -20,2 +20,2 @@\n-message: \"must contain @\"..." } - ] - }, - "agent:claude-code": { - "name": "Claude Code", - "provider": "anthropic", - "model": "claude-sonnet-4-20250514", - "identities": [ - {"system": "anthropic", "id": "claude-code/1.0.0"} - ] - }, - "tool:rustfmt": { - "name": "rustfmt", - "identities": [ - {"system": "crates.io", "id": "rustfmt-nightly/1.7.0"}, - {"system": "github", "id": "rust-lang/rustfmt"} - ] + }, + "meta": { + "intent": "Improve error messages for better UX" + } } - }, - - "signatures": [ - { - "signer": "human:alex", - "key": "gpg:ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ", - "scope": "author", - "timestamp": "2026-01-29T10:20:00Z", - "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(author signature)...\n-----END PGP SIGNATURE-----" - }, - { - "signer": "human:bob", - "key": "gpg:WXYZ987654321FEDCBA", - "scope": "reviewer", - "timestamp": "2026-01-29T11:00:00Z", - "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(reviewer signature)...\n-----END PGP SIGNATURE-----" + ], + "meta": { + "title": "Add email validation", + "source": "github:myorg/myrepo/pull/42", + "refs": [ + { + "rel": "fixes", + "href": "issue://github/myorg/myrepo/issues/42" + } + ], + "actors": { + "human:alex": { + "name": "Alex Kesling", + "identities": [ + { + "system": "github", + "id": "akesling" + }, + { + "system": "email", + "id": "alex@empathic.dev" + }, + { + "system": "orcid", + "id": "0000-0001-2345-6789" + } + ], + "keys": [ + { + "type": "gpg", + "fingerprint": "ABCD 1234 5678 90EF GHIJ KLMN OPQR STUV WXYZ", + "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/..." + } + ] + }, + "human:bob": { + "name": "Bob Reviewer", + "identities": [ + { + "system": "github", + "id": "bobreviewer" + }, + { + "system": "email", + "id": "bob@example.com" + } + ], + "keys": [ + { + "type": "gpg", + "fingerprint": "WXYZ 9876 5432 10FE DCBA", + "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/..." + } + ] + }, + "agent:claude-code": { + "name": "Claude Code", + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "identities": [ + { + "system": "anthropic", + "id": "claude-code/1.0.0" + } + ] + }, + "tool:rustfmt": { + "name": "rustfmt", + "identities": [ + { + "system": "crates.io", + "id": "rustfmt-nightly/1.7.0" + }, + { + "system": "github", + "id": "rust-lang/rustfmt" + } + ] + } }, - { - "signer": "ci:github-actions", - "key": "sigstore:github-actions/myorg/myrepo", - "scope": "ci", - "timestamp": "2026-01-29T10:25:00Z", - "sig": "eyJhbGciOiJFUzI1NiIs...(sigstore attestation)..." - } - ] + "signatures": [ + { + "signer": "human:alex", + "key": "gpg:ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ", + "scope": "author", + "timestamp": "2026-01-29T10:20:00Z", + "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(author signature)...\n-----END PGP SIGNATURE-----" + }, + { + "signer": "human:bob", + "key": "gpg:WXYZ987654321FEDCBA", + "scope": "reviewer", + "timestamp": "2026-01-29T11:00:00Z", + "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...(reviewer signature)...\n-----END PGP SIGNATURE-----" + }, + { + "signer": "ci:github-actions", + "key": "sigstore:github-actions/myorg/myrepo", + "scope": "ci", + "timestamp": "2026-01-29T10:25:00Z", + "sig": "eyJhbGciOiJFUzI1NiIs...(sigstore attestation)..." + } + ] + } } - } + ] } diff --git a/examples/path-04-exploration.path.json b/examples/path-04-exploration.path.json index 9b98d9f..e9da256 100644 --- a/examples/path-04-exploration.path.json +++ b/examples/path-04-exploration.path.json @@ -1,154 +1,173 @@ { - "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 }" - } + "graph": { + "id": "graph-path-explore-cli-args" + }, + "paths": [ + { + "path": { + "id": "path-explore-cli-args", + "base": { + "uri": "github:myorg/myrepo", + "ref": "main" }, - "meta": { - "intent": "Scaffold CLI entry point with basic arg check" - } + "head": "step-004" }, - { - "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\"] }" + "steps": [ + { + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-02-10T09:00:00Z" }, - "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 }" + "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" } }, - "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 \"); }" + { + "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)" } }, - "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" + { + "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" } }, - "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\"" + { + "step": { + "id": "step-003b", + "parents": [ + "step-002b" + ], + "actor": "tool:clippy/0.1.84", + "timestamp": "2026-02-10T09:09:00Z" }, - "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!() }" + "change": { + "src/main.rs": { + "structural": { + "type": "rust.rename", + "from": "_args", + "to": "args" + } + } + }, + "meta": { + "intent": "Fix clippy: unused variable prefix" } }, - "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\"))" + { + "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)" } }, - "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+}" + { + "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" } }, - "meta": { - "intent": "Merge builder API with manual match dispatch, wire up verbose flag" + { + "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" + ], + "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/examples/path-04-exploration.path.jsonl b/examples/path-04-exploration.path.jsonl index 8e81169..a1b0b0d 100644 --- a/examples/path-04-exploration.path.jsonl +++ b/examples/path-04-exploration.path.jsonl @@ -2,10 +2,10 @@ {"ActorDef":{"actor":"agent:claude-code/session-exp1","definition":{"name":"Claude Code","provider":"Anthropic","model":"claude-sonnet-4-20250514"}}} {"ActorDef":{"actor":"human:alex","definition":{"name":"Alex","identities":[{"system":"github","id":"alexk"}]}}} {"Step":{"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":{"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":{"step":{"id":"step-002a","parents":["step-001"],"actor":"agent:claude-code/session-exp1","timestamp":"2026-02-10T09:05:00Z"},"change":{"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 }"},"Cargo.toml":{"raw":"@@ -6,0 +7,2 @@\n+[dependencies]\n+clap = { version = \"4\", features = [\"derive\"] }"}},"meta":{"intent":"Try clap derive macros (abandoned: too much codegen)"}}} {"Step":{"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":{"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":{"step":{"id":"step-002c","parents":["step-001"],"actor":"agent:claude-code/session-exp1","timestamp":"2026-02-10T09:12:00Z"},"change":{"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!() }"},"Cargo.toml":{"raw":"@@ -6,0 +7,2 @@\n+[dependencies]\n+clap = \"4\""}},"meta":{"intent":"Try clap builder API (no derive macros)"}}} +{"Step":{"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","to":"args","from":"_args"}}},"meta":{"intent":"Fix clippy: unused variable prefix"}}} +{"Step":{"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":{"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":{"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"}}} {"Head":{"step_id":"step-004"}} diff --git a/examples/step-01-minimal.json b/examples/step-01-minimal.json index 7d3aecb..b4bc4f8 100644 --- a/examples/step-01-minimal.json +++ b/examples/step-01-minimal.json @@ -1,15 +1,27 @@ { - "Step": { - "step": { - "id": "step-001", - "actor": "human:alex", - "timestamp": "2026-01-29T10:00:00Z" - }, - - "change": { - "src/main.rs": { - "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" - } + "graph": { + "id": "graph-step-001" + }, + "paths": [ + { + "path": { + "id": "path-step-001", + "head": "step-001" + }, + "steps": [ + { + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-01-29T10:00:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" + } + } + } + ] } - } + ] } diff --git a/examples/step-02-agent.json b/examples/step-02-agent.json index c9cd6c5..e8c78fb 100644 --- a/examples/step-02-agent.json +++ b/examples/step-02-agent.json @@ -1,37 +1,77 @@ { - "Step": { - "step": { - "id": "step-002", - "parents": ["step-001"], - "actor": "agent:claude-code/session-abc123", - "timestamp": "2026-01-29T10:05:00Z" - }, - - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,25 @@\n use std::error::Error;\n \n+#[derive(Debug)]\n+pub struct ValidationError {\n+ pub field: String,\n+ pub message: String,\n+}\n+\n+impl std::fmt::Display for ValidationError {\n+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n+ write!(f, \"{}: {}\", self.field, self.message)\n+ }\n+}\n+\n+impl Error for ValidationError {}\n+\n+pub fn validate_email(input: &str) -> Result<(), ValidationError> {\n+ if !input.contains('@') {\n+ return Err(ValidationError {\n+ field: \"email\".to_string(),\n+ message: \"must contain @\".to_string(),\n+ });\n+ }\n+ Ok(())\n+}\n", - "structural": { - "type": "rust.add_items", - "items": [ - {"kind": "struct", "name": "ValidationError", "derives": ["Debug"]}, - {"kind": "impl", "trait": "std::fmt::Display", "for": "ValidationError"}, - {"kind": "impl", "trait": "Error", "for": "ValidationError"}, - {"kind": "fn", "name": "validate_email", "signature": "fn validate_email(input: &str) -> Result<(), ValidationError>"} - ] - } + "graph": { + "id": "graph-step-002" + }, + "paths": [ + { + "path": { + "id": "path-step-002", + "head": "step-002" }, - "src/auth/mod.rs": { - "raw": "@@ -1,1 +1,2 @@\n pub mod login;\n+pub mod validator;" - } - }, - - "meta": { - "intent": "Add email validation to prevent malformed input from reaching the database", - "refs": [ - {"rel": "fixes", "href": "issue://github/myrepo/issues/42"}, - {"rel": "implements", "href": "doc://design/input-validation-2026q1.md"}, - {"rel": "reasoning", "href": "agent://claude-code/session-abc123/turn/3"} + "steps": [ + { + "step": { + "id": "step-002", + "parents": [ + "step-001" + ], + "actor": "agent:claude-code/session-abc123", + "timestamp": "2026-01-29T10:05:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,25 @@\n use std::error::Error;\n \n+#[derive(Debug)]\n+pub struct ValidationError {\n+ pub field: String,\n+ pub message: String,\n+}\n+\n+impl std::fmt::Display for ValidationError {\n+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n+ write!(f, \"{}: {}\", self.field, self.message)\n+ }\n+}\n+\n+impl Error for ValidationError {}\n+\n+pub fn validate_email(input: &str) -> Result<(), ValidationError> {\n+ if !input.contains('@') {\n+ return Err(ValidationError {\n+ field: \"email\".to_string(),\n+ message: \"must contain @\".to_string(),\n+ });\n+ }\n+ Ok(())\n+}\n", + "structural": { + "type": "rust.add_items", + "items": [ + { + "kind": "struct", + "name": "ValidationError", + "derives": [ + "Debug" + ] + }, + { + "kind": "impl", + "trait": "std::fmt::Display", + "for": "ValidationError" + }, + { + "kind": "impl", + "trait": "Error", + "for": "ValidationError" + }, + { + "kind": "fn", + "name": "validate_email", + "signature": "fn validate_email(input: &str) -> Result<(), ValidationError>" + } + ] + } + }, + "src/auth/mod.rs": { + "raw": "@@ -1,1 +1,2 @@\n pub mod login;\n+pub mod validator;" + } + }, + "meta": { + "intent": "Add email validation to prevent malformed input from reaching the database", + "refs": [ + { + "rel": "fixes", + "href": "issue://github/myrepo/issues/42" + }, + { + "rel": "implements", + "href": "doc://design/input-validation-2026q1.md" + }, + { + "rel": "reasoning", + "href": "agent://claude-code/session-abc123/turn/3" + } + ] + } + } ] } - } + ] } diff --git a/examples/step-03-formatter.json b/examples/step-03-formatter.json index 22118a2..9f6edf5 100644 --- a/examples/step-03-formatter.json +++ b/examples/step-03-formatter.json @@ -1,20 +1,33 @@ { - "Step": { - "step": { - "id": "step-003", - "parents": ["step-002"], - "actor": "tool:rustfmt/1.7.0", - "timestamp": "2026-01-29T10:05:30Z" - }, - - "change": { - "src/auth/validator.rs": { - "raw": "@@ -15,4 +15,8 @@\n-pub fn validate_email(input: &str) -> Result<(), ValidationError> {\n- if !input.contains('@') {\n- return Err(ValidationError { field: \"email\".to_string(), message: \"must contain @\".to_string() });\n+pub fn validate_email(\n+ input: &str,\n+) -> Result<(), ValidationError> {\n+ if !input.contains('@') {\n+ return Err(ValidationError {\n+ field: \"email\".to_string(),\n+ message: \"must contain @\".to_string(),\n+ });" - } - }, - - "meta": { - "intent": "Automatic code formatting" + "graph": { + "id": "graph-step-003" + }, + "paths": [ + { + "path": { + "id": "path-step-003", + "head": "step-003" + }, + "steps": [ + { + "step": { + "id": "step-003", + "parents": [ + "step-002" + ], + "actor": "tool:rustfmt/1.7.0", + "timestamp": "2026-01-29T10:05:30Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -15,4 +15,8 @@\n-pub fn validate_email(input: &str) -> Result<(), ValidationError> {\n- if !input.contains('@') {\n- return Err(ValidationError { field: \"email\".to_string(), message: \"must contain @\".to_string() });\n+pub fn validate_email(\n+ input: &str,\n+) -> Result<(), ValidationError> {\n+ if !input.contains('@') {\n+ return Err(ValidationError {\n+ field: \"email\".to_string(),\n+ message: \"must contain @\".to_string(),\n+ });" + } + }, + "meta": { + "intent": "Automatic code formatting" + } + } + ] } - } + ] } diff --git a/examples/step-04-human-refinement.json b/examples/step-04-human-refinement.json index 5179566..7002cc4 100644 --- a/examples/step-04-human-refinement.json +++ b/examples/step-04-human-refinement.json @@ -1,30 +1,52 @@ { - "Step": { - "step": { - "id": "step-004", - "parents": ["step-003"], - "actor": "human:alex", - "timestamp": "2026-01-29T10:15:00Z" - }, - - "change": { - "src/auth/validator.rs": { - "raw": "@@ -20,2 +20,2 @@\n- field: \"email\".to_string(),\n- message: \"must contain @\".to_string(),\n+ field: \"email\".into(),\n+ message: \"Invalid email format - missing @\".into(),", - "structural": { - "type": "rust.modify_expressions", - "changes": [ - {"from": "\"email\".to_string()", "to": "\"email\".into()"}, - {"from": "\"must contain @\".to_string()", "to": "\"Invalid email format - missing @\".into()"} - ] + "graph": { + "id": "graph-step-004" + }, + "paths": [ + { + "path": { + "id": "path-step-004", + "head": "step-004" + }, + "steps": [ + { + "step": { + "id": "step-004", + "parents": [ + "step-003" + ], + "actor": "human:alex", + "timestamp": "2026-01-29T10:15:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -20,2 +20,2 @@\n- field: \"email\".to_string(),\n- message: \"must contain @\".to_string(),\n+ field: \"email\".into(),\n+ message: \"Invalid email format - missing @\".into(),", + "structural": { + "type": "rust.modify_expressions", + "changes": [ + { + "from": "\"email\".to_string()", + "to": "\"email\".into()" + }, + { + "from": "\"must contain @\".to_string()", + "to": "\"Invalid email format - missing @\".into()" + } + ] + } + } + }, + "meta": { + "intent": "Improve error message clarity and use idiomatic .into()", + "refs": [ + { + "rel": "refines", + "href": "toolpath://step-002" + } + ] + } } - } - }, - - "meta": { - "intent": "Improve error message clarity and use idiomatic .into()", - "refs": [ - {"rel": "refines", "href": "toolpath://step-002"} ] } - } + ] } diff --git a/examples/step-05-dead-end.json b/examples/step-05-dead-end.json index bb57b90..cdefa56 100644 --- a/examples/step-05-dead-end.json +++ b/examples/step-05-dead-end.json @@ -1,29 +1,56 @@ { - "Step": { - "step": { - "id": "step-002a", - "parents": ["step-001"], - "actor": "agent:claude-code/session-abc123", - "timestamp": "2026-01-29T10:03:00Z" - }, - - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,5 +1,15 @@\n use std::error::Error;\n+use regex::Regex;\n+use lazy_static::lazy_static;\n+\n+lazy_static! {\n+ static ref EMAIL_REGEX: Regex = Regex::new(\n+ r\"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$\"\n+ ).unwrap();\n+}\n+\n+pub fn validate_email(input: &str) -> bool {\n+ EMAIL_REGEX.is_match(input)\n+}\n", - "structural": { - "type": "rust.add_items", - "items": [ - {"kind": "use", "path": "regex::Regex"}, - {"kind": "use", "path": "lazy_static::lazy_static"}, - {"kind": "macro_invocation", "macro": "lazy_static", "defines": "EMAIL_REGEX"}, - {"kind": "fn", "name": "validate_email", "signature": "fn validate_email(input: &str) -> bool"} - ] + "graph": { + "id": "graph-step-002a" + }, + "paths": [ + { + "path": { + "id": "path-step-002a", + "head": "step-002a" + }, + "steps": [ + { + "step": { + "id": "step-002a", + "parents": [ + "step-001" + ], + "actor": "agent:claude-code/session-abc123", + "timestamp": "2026-01-29T10:03:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,5 +1,15 @@\n use std::error::Error;\n+use regex::Regex;\n+use lazy_static::lazy_static;\n+\n+lazy_static! {\n+ static ref EMAIL_REGEX: Regex = Regex::new(\n+ r\"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$\"\n+ ).unwrap();\n+}\n+\n+pub fn validate_email(input: &str) -> bool {\n+ EMAIL_REGEX.is_match(input)\n+}\n", + "structural": { + "type": "rust.add_items", + "items": [ + { + "kind": "use", + "path": "regex::Regex" + }, + { + "kind": "use", + "path": "lazy_static::lazy_static" + }, + { + "kind": "macro_invocation", + "macro": "lazy_static", + "defines": "EMAIL_REGEX" + }, + { + "kind": "fn", + "name": "validate_email", + "signature": "fn validate_email(input: &str) -> bool" + } + ] + } + } + }, + "meta": { + "intent": "Validate email addresses using regex pattern matching" + } } - } - }, - - "meta": { - "intent": "Validate email addresses using regex pattern matching" + ] } - } + ] } diff --git a/examples/step-06-signed.json b/examples/step-06-signed.json index 1b65203..e386b7e 100644 --- a/examples/step-06-signed.json +++ b/examples/step-06-signed.json @@ -1,50 +1,70 @@ { - "Step": { - "step": { - "id": "step-001", - "actor": "human:alex", - "timestamp": "2026-01-29T10:00:00Z" - }, - - "change": { - "src/main.rs": { - "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" - } - }, - - "meta": { - "intent": "Fix greeting punctuation", - "actors": { - "human:alex": { - "name": "Alex Kesling", - "identities": [ - {"system": "github", "id": "akesling"}, - {"system": "twitter", "id": "AlexKesling"}, - {"system": "email", "id": "alex@empathic.dev"} - ], - "keys": [ - { - "type": "gpg", - "fingerprint": "ABCD 1234 5678 90EF GHIJ KLMN OPQR STUV WXYZ", - "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ" - }, - { - "type": "ssh", - "fingerprint": "SHA256:abcdefghijklmnopqrstuvwxyz123456789", - "href": "https://github.com/akesling.keys" - } - ] - } + "graph": { + "id": "graph-step-001" + }, + "paths": [ + { + "path": { + "id": "path-step-001", + "head": "step-001" }, - "signatures": [ + "steps": [ { - "signer": "human:alex", - "key": "gpg:ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ", - "scope": "author", - "timestamp": "2026-01-29T10:00:05Z", - "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...\n-----END PGP SIGNATURE-----" + "step": { + "id": "step-001", + "actor": "human:alex", + "timestamp": "2026-01-29T10:00:00Z" + }, + "change": { + "src/main.rs": { + "raw": "@@ -12,1 +12,1 @@\n- println!(\"Hello world\");\n+ println!(\"Hello, world!\");" + } + }, + "meta": { + "intent": "Fix greeting punctuation", + "actors": { + "human:alex": { + "name": "Alex Kesling", + "identities": [ + { + "system": "github", + "id": "akesling" + }, + { + "system": "twitter", + "id": "AlexKesling" + }, + { + "system": "email", + "id": "alex@empathic.dev" + } + ], + "keys": [ + { + "type": "gpg", + "fingerprint": "ABCD 1234 5678 90EF GHIJ KLMN OPQR STUV WXYZ", + "href": "https://keys.openpgp.org/vks/v1/by-fingerprint/ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ" + }, + { + "type": "ssh", + "fingerprint": "SHA256:abcdefghijklmnopqrstuvwxyz123456789", + "href": "https://github.com/akesling.keys" + } + ] + } + }, + "signatures": [ + { + "signer": "human:alex", + "key": "gpg:ABCD1234567890EFGHIJKLMNOPQRSTUVWXYZ", + "scope": "author", + "timestamp": "2026-01-29T10:00:05Z", + "sig": "-----BEGIN PGP SIGNATURE-----\n\niQIzBAABCAAdFiEE...\n-----END PGP SIGNATURE-----" + } + ] + } } ] } - } + ] } diff --git a/examples/step-07-merge.json b/examples/step-07-merge.json index fa9b756..dfb7fb6 100644 --- a/examples/step-07-merge.json +++ b/examples/step-07-merge.json @@ -1,20 +1,34 @@ { - "Step": { - "step": { - "id": "step-004", - "parents": ["step-002a", "step-003b"], - "actor": "human:alex", - "timestamp": "2026-01-29T12:00:00Z" - }, - - "change": { - "src/auth/validator.rs": { - "raw": "@@ -1,10 +1,20 @@\n...(merged changes from both branches)..." - } - }, - - "meta": { - "intent": "Merge validation improvements from feature-A with logging from feature-B" + "graph": { + "id": "graph-step-004" + }, + "paths": [ + { + "path": { + "id": "path-step-004", + "head": "step-004" + }, + "steps": [ + { + "step": { + "id": "step-004", + "parents": [ + "step-002a", + "step-003b" + ], + "actor": "human:alex", + "timestamp": "2026-01-29T12:00:00Z" + }, + "change": { + "src/auth/validator.rs": { + "raw": "@@ -1,10 +1,20 @@\n...(merged changes from both branches)..." + } + }, + "meta": { + "intent": "Merge validation improvements from feature-A with logging from feature-B" + } + } + ] } - } + ] } diff --git a/schema/toolpath.schema.json b/schema/toolpath.schema.json index 59da596..4ba365c 100644 --- a/schema/toolpath.schema.json +++ b/schema/toolpath.schema.json @@ -463,36 +463,5 @@ } }, - "oneOf": [ - { - "type": "object", - "title": "Step document", - "description": "Externally tagged Step: {\"Step\": {…}}", - "properties": { - "Step": { "$ref": "#/$defs/step" } - }, - "required": ["Step"], - "additionalProperties": false - }, - { - "type": "object", - "title": "Path document", - "description": "Externally tagged Path: {\"Path\": {…}}", - "properties": { - "Path": { "$ref": "#/$defs/path" } - }, - "required": ["Path"], - "additionalProperties": false - }, - { - "type": "object", - "title": "Graph document", - "description": "Externally tagged Graph: {\"Graph\": {…}}", - "properties": { - "Graph": { "$ref": "#/$defs/graph" } - }, - "required": ["Graph"], - "additionalProperties": false - } - ] + "$ref": "#/$defs/graph" } diff --git a/site/_data/crates.json b/site/_data/crates.json index b09e4ee..d3d666a 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -1,11 +1,11 @@ [ { "name": "toolpath", - "version": "0.2.0", + "version": "0.3.0", "description": "Core types, builders, and query API", "docs": "https://docs.rs/toolpath", "crate": "https://crates.io/crates/toolpath", - "role": "The gravity well. All other crates depend on this; it depends on nothing except serde. Defines Step, Path, Graph, and the query functions to traverse them." + "role": "The gravity well. All other crates depend on this; it depends on nothing except serde. Defines Graph (the single root document type), Path, Step, and the query functions to traverse them." }, { "name": "toolpath-convo", @@ -17,11 +17,11 @@ }, { "name": "toolpath-git", - "version": "0.1.4", + "version": "0.2.0", "description": "Derive from git repository history", "docs": "https://docs.rs/toolpath-git", "crate": "https://crates.io/crates/toolpath-git", - "role": "Reads git history via libgit2 and maps commits to Steps, branches to Paths. Single branch produces a Path; multiple branches produce a Graph." + "role": "Reads git history via libgit2 and maps commits to Steps, branches to Paths. Returns a Graph: a single branch yields a single-path graph; multiple branches yield a multi-path graph." }, { "name": "toolpath-github", @@ -65,7 +65,7 @@ }, { "name": "toolpath-pi", - "version": "0.2.0", + "version": "0.3.0", "description": "Derive Toolpath provenance documents from Pi (pi.dev) agent session logs", "docs": "https://docs.rs/toolpath-pi", "crate": "https://crates.io/crates/toolpath-pi", @@ -73,23 +73,23 @@ }, { "name": "toolpath-dot", - "version": "0.1.3", + "version": "0.2.0", "description": "Graphviz DOT visualization", "docs": "https://docs.rs/toolpath-dot", "crate": "https://crates.io/crates/toolpath-dot", - "role": "Renders any Toolpath Document as a Graphviz diagram. Steps are color-coded by actor type, dead ends get red dashed borders, and the DAG structure is preserved visually." + "role": "Renders a Toolpath Graph as a Graphviz diagram. Steps are color-coded by actor type, dead ends get red dashed borders, single-path graphs use a path-focused layout, multi-path graphs use clustered subgraphs." }, { "name": "toolpath-md", - "version": "0.2.1", + "version": "0.3.0", "description": "Markdown rendering for LLM consumption", "docs": "https://docs.rs/toolpath-md", "crate": "https://crates.io/crates/toolpath-md", - "role": "Renders any Toolpath Document as readable Markdown — a narrative an LLM can reason about. Dead ends are called out explicitly, diffs are included at configurable detail levels, and the output preserves enough anchoring info for an LLM to reference back into the original document." + "role": "Renders a Toolpath Graph as readable Markdown — a narrative an LLM can reason about. Dead ends are called out explicitly, diffs are included at configurable detail levels, and the output preserves enough anchoring info for an LLM to reference back into the original document." }, { "name": "path-cli", - "version": "0.5.0", + "version": "0.6.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -97,7 +97,7 @@ }, { "name": "toolpath-cli", - "version": "0.5.1", + "version": "0.6.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli",