diff --git a/CHANGELOG.md b/CHANGELOG.md index 779cadb7..bfaf316b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the Toolpath workspace are documented here. +## toolpath 0.7.1 — 2026-08-14 + +Adds an optional `description` field to `StepMeta`, `PathMeta`, and +`GraphMeta` (issue #181): a human-readable summary of the object itself, +distinct from `intent`, which records the initial goal of the work. The +field is promoted from `additionalProperties` to a first-class key in +the JSON Schema and typed structs, and carried through the JSONL +`PathOpen`/`PathMeta` line kinds, so existing documents are unaffected. +The schema and RFC descriptions of `intent` were reworded to match its +actual definition. + ## `path config edit` — 2026-08-14 - **`path-cli`** (0.18.0): new `path config` porcelain command, starting diff --git a/Cargo.lock b/Cargo.lock index 4be9d924..24b36aeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4218,7 +4218,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "toolpath" -version = "0.7.0" +version = "0.7.1" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b57ad62b..d823e0e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ edition = "2024" license = "Apache-2.0" [workspace.dependencies] -toolpath = { version = "0.7.0", path = "crates/toolpath" } +toolpath = { version = "0.7.1", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false } diff --git a/RFC.md b/RFC.md index fa819525..376339b1 100644 --- a/RFC.md +++ b/RFC.md @@ -279,13 +279,18 @@ identity and key information are provided in `meta.actors`. The `meta` object holds all optional metadata. It can appear on both steps and paths. -| Field | Description | -| ------------ | -------------------------------------------------- | -| `kind` | Path kind — see [Document Kind](#document-kind) (paths only) | -| `intent` | Human-readable description of purpose | -| `refs` | Links to issues, docs, reasoning | -| `actors` | Actor definitions with identities and keys | -| `signatures` | Cryptographic signatures for verification | +| Field | Description | +| ------------- | -------------------------------------------------- | +| `kind` | Path kind — see [Document Kind](#document-kind) (paths only) | +| `intent` | The initial goal of the work | +| `description` | Human-readable summary of the object itself | +| `refs` | Links to issues, docs, reasoning | +| `actors` | Actor definitions with identities and keys | +| `signatures` | Cryptographic signatures for verification | + +`intent` and `description` differ in what they describe: `intent` records +what the work set out to do, fixed at the outset; `description` summarizes +the object as it stands, and may be written or revised after the fact. #### Document Kind diff --git a/crates/toolpath/Cargo.toml b/crates/toolpath/Cargo.toml index 77f38495..7d3fe8f3 100644 --- a/crates/toolpath/Cargo.toml +++ b/crates/toolpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath" -version = "0.7.0" +version = "0.7.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath/schema/toolpath.schema.json b/crates/toolpath/schema/toolpath.schema.json index 59efd48e..b4bf57d9 100644 --- a/crates/toolpath/schema/toolpath.schema.json +++ b/crates/toolpath/schema/toolpath.schema.json @@ -207,7 +207,11 @@ "properties": { "intent": { "type": "string", - "description": "Human-readable description of purpose" + "description": "The initial goal of the work" + }, + "description": { + "type": "string", + "description": "Human-readable summary of the step itself" }, "source": { "$ref": "#/$defs/vcsSource", @@ -348,7 +352,11 @@ }, "intent": { "type": "string", - "description": "Human-readable description of purpose" + "description": "The initial goal of the work" + }, + "description": { + "type": "string", + "description": "Human-readable summary of the path itself" }, "refs": { "type": "array", @@ -433,7 +441,11 @@ }, "intent": { "type": "string", - "description": "Human-readable description of purpose" + "description": "The initial goal of the work" + }, + "description": { + "type": "string", + "description": "Human-readable summary of the graph itself" }, "refs": { "type": "array", diff --git a/crates/toolpath/src/jsonl.rs b/crates/toolpath/src/jsonl.rs index 81e7f9f8..a3604032 100644 --- a/crates/toolpath/src/jsonl.rs +++ b/crates/toolpath/src/jsonl.rs @@ -106,6 +106,8 @@ pub struct PathOpenMeta { pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec, #[serde(flatten, default)] @@ -156,6 +158,8 @@ pub struct PathMetaPatch { #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub refs: Option>, #[serde(flatten, default)] pub extra: HashMap, @@ -354,6 +358,7 @@ impl Path { meta.kind = m.kind; meta.source = m.source; meta.intent = m.intent; + meta.description = m.description; meta.refs = m.refs; meta.extra = m.extra; } @@ -518,6 +523,9 @@ fn apply_meta_patch(path_meta: &mut PathMeta, patch: PathMetaPatch) { if let Some(v) = patch.intent { path_meta.intent = Some(v); } + if let Some(v) = patch.description { + path_meta.description = Some(v); + } if let Some(v) = patch.refs { path_meta.refs = v; } @@ -557,6 +565,7 @@ fn path_meta_is_empty(m: &PathMeta) -> bool { && m.kind.is_none() && m.source.is_none() && m.intent.is_none() + && m.description.is_none() && m.refs.is_empty() && m.actors.as_ref().is_none_or(|a| a.is_empty()) && m.signatures.is_empty() @@ -667,6 +676,7 @@ fn write_line(w: &mut W, line: &JsonlLine) -> Result<(), JsonlError> { fn step_meta_is_empty(m: &StepMeta) -> bool { m.intent.is_none() + && m.description.is_none() && m.source.is_none() && m.refs.is_empty() && m.actors.as_ref().is_none_or(|a| a.is_empty()) @@ -682,6 +692,7 @@ fn path_meta_for_open(m: &PathMeta) -> Option { kind: m.kind.clone(), source: m.source.clone(), intent: m.intent.clone(), + description: m.description.clone(), refs: m.refs.clone(), extra: m.extra.clone(), }; @@ -689,6 +700,7 @@ fn path_meta_for_open(m: &PathMeta) -> Option { && open.kind.is_none() && open.source.is_none() && open.intent.is_none() + && open.description.is_none() && open.refs.is_empty() && open.extra.is_empty() { @@ -1200,6 +1212,57 @@ mod tests { assert_eq!(canonical_json(&p), canonical_json(&back)); } + #[test] + fn roundtrip_description() { + let mut step = make_step("s1", None); + step.meta = Some(StepMeta { + description: Some("renames the config field".into()), + ..Default::default() + }); + let p = Path { + path: PathIdentity { + id: "p".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![step], + meta: Some(PathMeta { + intent: Some("fix the config bug".into()), + description: Some("a short session touching config parsing".into()), + ..Default::default() + }), + }; + let jsonl = p.to_jsonl_string().unwrap(); + let back = Path::from_jsonl_str(&jsonl).unwrap(); + assert_eq!(canonical_json(&p), canonical_json(&back)); + // Typed field, not the extra catch-all. + let meta = back.meta.unwrap(); + assert_eq!( + meta.description.as_deref(), + Some("a short session touching config parsing") + ); + assert!(meta.extra.is_empty()); + } + + #[test] + fn reader_path_meta_patch_description() { + let input = concat!( + r#"{"PathOpen":{"version":"1","id":"p"}}"#, + "\n", + r#"{"Step":{"step":{"id":"s1","actor":"a","timestamp":"t"},"change":{}}}"#, + "\n", + r#"{"PathMeta":{"patch":{"description":"summary written later"}}}"#, + "\n", + r#"{"Head":{"step_id":"s1"}}"#, + "\n", + ); + let path = Path::from_jsonl_str(input).unwrap(); + let meta = path.meta.unwrap(); + assert_eq!(meta.description.as_deref(), Some("summary written later")); + assert!(meta.extra.is_empty()); + } + #[test] fn roundtrip_dead_end_uses_explicit_head() { let p = path_with_dead_end(); diff --git a/crates/toolpath/src/types.rs b/crates/toolpath/src/types.rs index 0d84d534..0c480367 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -46,6 +46,8 @@ pub struct GraphMeta { pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -167,6 +169,8 @@ pub struct PathMeta { pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -279,6 +283,8 @@ pub struct StepMeta { #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec, diff --git a/site/_data/crates.json b/site/_data/crates.json index da0fcd41..898a045c 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -1,7 +1,7 @@ [ { "name": "toolpath", - "version": "0.7.0", + "version": "0.7.1", "description": "Core types, builders, and query API", "docs": "https://docs.rs/toolpath", "crate": "https://crates.io/crates/toolpath",