From a0bdfd103587dc0337e3a77f82390501c30f94e9 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 29 Jun 2026 14:27:30 -0400 Subject: [PATCH 1/2] feat(cli): add `path query` (jaq over the cache) and `path kind` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path query` loads every step in the local cache into one JSON array and transforms it with an in-process jaq (pure-Rust jq) filter — selection, projection, ranking, grouping, and top-N are all jaq. Each array element wraps a Toolpath step (step/change/meta verbatim) with `cache_id`, `path` (the parent path's id/base/meta), and `dead_end` (off the head's ancestry). Scope flags choose which docs load: `--source`/`--id`/`--input` (file selection) and `--project`/`--kind` (content scoping, semver-prefix kind match). Output mirrors jq: pretty on a TTY, compact when piped (`-c`), raw strings with `-r`. Malformed cache docs are skipped with a stderr warning; empty results exit 0, filter errors exit 1. `path kind` is the cold-start companion: lists the bundled kind specs, or prints a kind's schema.json (the field reference for writing filters). `--kind` and `path kind` share one semver-prefix selector, sourced from a new shared `kinds` module that `schema.rs` now also reads. BREAKING (pre-1.0): the former `path query` subcommands change. `ancestors` moves to `path p query ancestors`; `dead-ends`/`filter` become jaq forms (`map(select(.dead_end))`, `map(select(.step.actor | startswith("agent:")))`). path-cli 0.14.0 -> 0.15.0; adds jaq-core/jaq-std/jaq-json. --- CHANGELOG.md | 35 ++ CLAUDE.md | 16 +- Cargo.lock | 193 ++++++++- Cargo.toml | 2 +- README.md | 15 +- crates/path-cli/Cargo.toml | 9 +- crates/path-cli/README.md | 55 ++- crates/path-cli/src/cmd_kind.rs | 101 +++++ crates/path-cli/src/cmd_p.rs | 6 + crates/path-cli/src/cmd_p_query.rs | 117 +++++ crates/path-cli/src/cmd_query.rs | 401 +++++------------- crates/path-cli/src/kinds.rs | 234 ++++++++++ crates/path-cli/src/lib.rs | 20 +- crates/path-cli/src/query/filter.rs | 184 ++++++++ crates/path-cli/src/query/mod.rs | 394 +++++++++++++++++ crates/path-cli/src/schema.rs | 33 +- crates/path-cli/tests/integration.rs | 9 +- crates/path-cli/tests/query.rs | 337 +++++++++++++++ .../2026-06-22-path-query-command-design.md | 271 ++++++++++++ site/_data/crates.json | 2 +- site/index.md | 8 +- site/pages/cli.md | 4 +- 22 files changed, 2080 insertions(+), 366 deletions(-) create mode 100644 crates/path-cli/src/cmd_kind.rs create mode 100644 crates/path-cli/src/cmd_p_query.rs create mode 100644 crates/path-cli/src/kinds.rs create mode 100644 crates/path-cli/src/query/filter.rs create mode 100644 crates/path-cli/src/query/mod.rs create mode 100644 crates/path-cli/tests/query.rs create mode 100644 docs/superpowers/specs/2026-06-22-path-query-command-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 895f5e07..a983ff47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to the Toolpath workspace are documented here. +## `path query` — query the local cache with jaq — 2026-06-29 + +Adds `path query`, a porcelain that loads every step in the local cache +(`~/.toolpath/documents/`) into a single JSON array and transforms it with +an in-process jaq (pure-Rust jq) filter. Selection, projection, ranking, +grouping, and top-N are all just jaq, so one command answers "find every +turn that mentions `RefCell`", "which sessions touched `cmd_resume.rs`?", +"the 10 steps that cost the most tokens", and so on. + +- **`path query [scope flags] ['']`** — each array element is a + Toolpath step (`step`/`change`/`meta` verbatim) wrapped with `cache_id`, + `path` (the parent path's `id`/`base`/`meta`), and `dead_end` (whether the + step is off the head's ancestry). With no filter it emits the array; a + filter is the same as piping that array to `jq`. Output mirrors jq: + pretty on a TTY, compact when piped (`-c` forces compact), and `-r` + prints string results unquoted (pipe a column of ids/paths into another + command, or read a turn's text/diff unescaped). +- **Scope flags.** File selection (before parse): `--source ` + (cache-id prefix, e.g. `claude`/`git`), `--id ` (repeatable), + `--input ` (`-` for stdin, repeatable). Content scoping (per path): + `--project ` (matches a `file://` base) and `--kind ` + (semver-prefix match, e.g. `agent-coding-session/v1`). Everything else is + a jaq predicate on the real structure. +- **`path kind`** — the cold-start companion: lists the kinds the binary + bundles a spec for; `path kind ` prints that kind's bundled + `schema.json` (the per-field type + semantics reference for writing + filters). A trailing `/` pins a version with the same prefix + rule as `--kind`. + +**Breaking** (pre-1.0): the former `path query` subcommands change. +`ancestors` moves to `path p query ancestors`; `dead-ends` and `filter` +are gone in favor of jaq forms — `path query 'map(select(.dead_end))'` and +`path query 'map(select(.step.actor | startswith("agent:")))'`. `path-cli` +0.14.0 → 0.15.0; adds the `jaq-core`/`jaq-std`/`jaq-json` dependencies. + ## Token usage: once per message, with per-step attribution + kind v1.1.0 — 2026-06-17 Fixes token over-counting in derived documents (~3× output-token diff --git a/CLAUDE.md b/CLAUDE.md index 01c70e9f..022309fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,10 +75,10 @@ Requires Rust 1.85+ (edition 2024). Pinned to 1.94.0 via `rust-toolchain.toml`. The binary is called `path` (package: `path-cli`; the older `toolpath-cli` package is a deprecated shim that still installs the same binary for users running `cargo install toolpath-cli`): The top-level surface is the porcelain (`show`, `share`, `resume`, -`query`, `auth`, `haiku`). Lower-level building blocks live under +`query`, `kind`, `auth`, `haiku`). Lower-level building blocks live under `path p …` (plumbing): `p list`, `p import`, `p export`, `p cache`, `p render`, `p merge`, `p validate`, `p derive`, `p project`, -`p incept`, `p track`. +`p incept`, `p track`, `p query` (graph traversal: `ancestors`). ```bash # Plumbing: import from external formats into the local toolpath cache @@ -118,9 +118,13 @@ cargo run -p path-cli -- p cache rm # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json cargo run -p path-cli -- p render md --input doc.json --detail full -cargo run -p path-cli -- query dead-ends --input doc.json -cargo run -p path-cli -- query ancestors --input doc.json --step-id step-003 -cargo run -p path-cli -- query filter --input doc.json --actor "agent:" +# Query the whole local cache with a jaq (jq) filter over wrapped steps +cargo run -p path-cli -- query 'map(select(.dead_end)) | map(.step.id)' +cargo run -p path-cli -- query --source claude 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' +cargo run -p path-cli -- query --input doc.json 'map(select(.step.actor | startswith("agent:")))' +cargo run -p path-cli -- kind # list bundled kinds +cargo run -p path-cli -- kind agent-coding-session # print a kind's schema (field reference) +cargo run -p path-cli -- p query ancestors --input doc.json --step-id step-003 cargo run -p path-cli -- p merge doc1.json doc2.json --title "Combined" cargo run -p path-cli -- p list git --repo . cargo run -p path-cli -- p list github --repo owner/repo @@ -186,7 +190,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 294 unit + 65 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 309 unit + 86 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` diff --git a/Cargo.lock b/Cargo.lock index 77e86508..c693038f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,38 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deltae" version = "0.3.2" @@ -1257,6 +1289,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hifijson" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "242402749acf71e6f32f5857598b7002c4058a4e3c3b22b4c7d51cab9aea754e" + [[package]] name = "http" version = "1.4.0" @@ -1629,6 +1667,95 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jaq-core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7561783b20275a6c9cb576e39208b0c635f34ef14357f1f05a2927a774f3adec" +dependencies = [ + "dyn-clone", + "once_cell", + "typed-arena", +] + +[[package]] +name = "jaq-json" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec9aaad7340e6990c6c1878ef3b46dbec624e535d7f786cc9ddcf94f773d33" +dependencies = [ + "bstr", + "bytes", + "foldhash 0.1.5", + "hifijson", + "indexmap", + "jaq-core", + "jaq-std", + "num-bigint", + "num-traits", + "ryu", + "self_cell", +] + +[[package]] +name = "jaq-std" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdc5a74b0feeb5e6a1dc2dd08c34280a61e37668d10a6a3b27ad69d0fb9ce2e" +dependencies = [ + "aho-corasick", + "base64", + "bstr", + "jaq-core", + "jiff", + "libm", + "log", + "regex-bites", + "urlencoding", +] + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -1806,6 +1933,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libmimalloc-sys" version = "0.1.44" @@ -2308,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "assert_cmd", @@ -2317,6 +2450,9 @@ dependencies = [ "git2", "hex", "insta", + "jaq-core", + "jaq-json", + "jaq-std", "jsonschema", "pathbase-client", "predicates", @@ -2489,6 +2625,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.9.0" @@ -2574,6 +2719,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2948,6 +3115,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-bites" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a15a2fa0bfda9361941c45550896ae87b15cc6c8c939ea350079670332e211" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3251,6 +3424,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -4139,6 +4318,12 @@ dependencies = [ "vt100", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -4287,6 +4472,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index b03c2d21..7fbe44d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" } -path-cli = { version = "0.14.0", path = "crates/path-cli" } +path-cli = { version = "0.15.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/README.md b/README.md index b08e3c83..16fe024c 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,17 @@ path p import pathbase https://pathbase.dev/alex/pathstash/path-pr-42 path resume https://pathbase.dev/alex/pathstash/path-pr-42 path resume claude- --harness claude -C /path/to/project -# Query for dead ends (abandoned approaches) -path query dead-ends --input doc.json +# Query the whole local cache with a jaq (jq) filter over wrapped steps +# (e.g. dead ends, or turns by an agent actor) +path query 'map(select(.dead_end))' +path query --input doc.json 'map(select(.step.actor | startswith("agent:")))' -# Filter steps by actor -path query filter --input doc.json --actor "agent:" +# List bundled document kinds, or print a kind's schema (the field reference) +path kind +path kind agent-coding-session -# Walk the ancestry of a step -path query ancestors --input doc.json --step-id step-003 +# Walk the ancestry of a step (plumbing) +path p query ancestors --input doc.json --step-id step-003 # Merge multiple documents into a graph path p merge doc1.json doc2.json --title "Release v2" --pretty diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 01eea174..fa3ae7d1 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.14.0" +version = "0.15.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -37,6 +37,13 @@ rand = "0.9" jsonschema = { version = "0.46", default-features = false } sha2 = "0.10" hex = "0.4" +# jaq (pure-Rust jq) powers `path query`'s in-process filter. Default +# features stay on (notably `regex`, so `test`/`match` work); trimming +# jaq-std here is moot because jaq-json depends on jaq-std with defaults +# and feature unification re-enables them. +jaq-core = "3.1.0" +jaq-std = "3.0.1" +jaq-json = "2.0.1" [target.'cfg(not(target_os = "emscripten"))'.dependencies] toolpath-claude = { workspace = true, features = ["watcher"] } diff --git a/crates/path-cli/README.md b/crates/path-cli/README.md index 160c4289..fbb6abf5 100644 --- a/crates/path-cli/README.md +++ b/crates/path-cli/README.md @@ -42,7 +42,7 @@ path p import git --repo . --branch main:HEAD~20 --no-cache | path p render dot **Review what an AI agent changed:** ```bash -path p import claude --project . --no-cache --pretty | path query filter --actor "agent:" --pretty +path p import claude --project . --no-cache | path query --input - 'map(select(.step.actor | startswith("agent:")))' ``` **Record provenance for a live editing session:** @@ -101,19 +101,54 @@ path p import claude --project /path/to/project --all ### query -Query Toolpath documents. +Load every step in the local cache into one JSON array and transform it with +an in-process jaq (jq) filter. Each element wraps a Toolpath step with +`cache_id`, `path` (the parent path's `id`/`base`/`meta`), and `dead_end`. +Scope flags choose which documents load; the filter does the rest. ```bash -# Walk ancestry from a step -path query ancestors --input doc.json --step-id step-003 +# Find abandoned branches (the former `dead-ends` subcommand) +path query 'map(select(.dead_end))' + +# Steps by an agent actor (the former `filter --actor`) +path query --input doc.json 'map(select(.step.actor | startswith("agent:")))' + +# Turns over 50k input tokens, in Claude sessions only +path query --source claude 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' + +# Top 10 steps by total tokens +path query --kind agent-coding-session \ + 'map({step: .step.id, t: ([.change[].structural.token_usage//empty | (.input_tokens//0)+(.output_tokens//0)] | add//0)}) | sort_by(-.t) | .[:10]' + +# Raw output (-r): a column of ids straight into another command +path query -r '.[].cache_id' | sort -u +``` + +Scope flags: `--source ` / `--id ` / `--input ` (file +selection), `--project ` / `--kind ` (content scoping). Output +mirrors jq: pretty on a TTY, compact when piped (`-c` forces compact); `-r` +prints string results unquoted (for piping ids/paths onward, or reading +text/diff content unescaped). + +### kind -# Find abandoned branches -path query dead-ends --input doc.json +List the document kinds the binary bundles a spec for, or print a kind's +bundled `schema.json` — the per-field type and semantics reference for writing +`path query` filters. -# Filter by criteria (combinable) -path query filter --input doc.json --actor "agent:" -path query filter --input doc.json --artifact "src/main.rs" -path query filter --input doc.json --after "2026-01-29T00:00:00Z" --before "2026-01-30T00:00:00Z" +```bash +path kind # list bundled kinds +path kind agent-coding-session # newest version's schema +path kind agent-coding-session/v1.0.0 # pin a version +``` + +### p query + +Low-level graph traversal on a single document. + +```bash +# Walk ancestry from a step +path p query ancestors --input doc.json --step-id step-003 ``` ### p render diff --git a/crates/path-cli/src/cmd_kind.rs b/crates/path-cli/src/cmd_kind.rs new file mode 100644 index 00000000..955bcf07 --- /dev/null +++ b/crates/path-cli/src/cmd_kind.rs @@ -0,0 +1,101 @@ +//! `path kind` — the cold-start companion to `path query`. +//! +//! A step's shape is set by its path's *kind*. `path kind` lists the kinds the +//! binary bundles a spec for; `path kind ` prints that kind's bundled +//! `schema.json`, which names every field, its type, and (in `description` +//! fields) the semantics behind it. A trailing `/` pins one version, +//! matching the same semver-prefix rule as `path query --kind`. + +use anyhow::{Result, bail}; +use clap::Parser; + +use crate::kinds::{self, BUNDLED_KINDS}; + +#[derive(Parser, Debug)] +#[command(after_long_help = KIND_HELP)] +pub struct KindArgs { + /// Kind to show, e.g. `agent-coding-session` (newest version) or + /// `agent-coding-session/v1.0.0` (pinned). Omit to list bundled kinds. + kind: Option, +} + +const KIND_HELP: &str = "\ +Examples: + path kind list bundled kinds + path kind agent-coding-session print the newest bundled schema + path kind agent-coding-session/v1.0.0 pin a specific version"; + +pub fn run(args: KindArgs) -> Result<()> { + match args.kind { + None => { + list(); + Ok(()) + } + Some(selector) => print_schema(&selector), + } +} + +/// List bundled kinds, one name per line with its available versions. +fn list() { + let mut names: Vec<&str> = Vec::new(); + for k in BUNDLED_KINDS { + if !names.contains(&k.name) { + names.push(k.name); + } + } + for name in names { + let versions: Vec<&str> = BUNDLED_KINDS + .iter() + .filter(|k| k.name == name) + .map(|k| k.version) + .collect(); + println!("{name}\t{}", versions.join(", ")); + } +} + +fn print_schema(selector: &str) -> Result<()> { + match kinds::resolve(selector) { + Some(k) => { + print!("{}", k.schema); + if !k.schema.ends_with('\n') { + println!(); + } + Ok(()) + } + None => { + let available: Vec = BUNDLED_KINDS + .iter() + .map(|k| format!("{}/{}", k.name, k.version)) + .collect(); + bail!( + "no bundled spec for kind `{selector}`. Bundled kinds: {}", + available.join(", ") + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newest_version_resolves_and_is_json() { + let k = kinds::resolve("agent-coding-session").unwrap(); + assert_eq!(k.version, "v1.1.0"); + let _: serde_json::Value = + serde_json::from_str(k.schema).expect("bundled schema is valid JSON"); + } + + #[test] + fn pinned_version_resolves() { + let k = kinds::resolve("agent-coding-session/v1.0.0").unwrap(); + assert_eq!(k.version, "v1.0.0"); + } + + #[test] + fn print_schema_errors_for_unknown() { + let err = print_schema("nope").unwrap_err(); + assert!(err.to_string().contains("Bundled kinds")); + } +} diff --git a/crates/path-cli/src/cmd_p.rs b/crates/path-cli/src/cmd_p.rs index f86fa31e..3e079da6 100644 --- a/crates/path-cli/src/cmd_p.rs +++ b/crates/path-cli/src/cmd_p.rs @@ -86,6 +86,11 @@ pub enum PCommand { #[command(subcommand)] op: crate::cmd_track::TrackOp, }, + /// Low-level graph traversal on a single document (ancestors) + Query { + #[command(subcommand)] + op: crate::cmd_p_query::PQueryOp, + }, } pub fn run(command: PCommand, pretty: bool) -> Result<()> { @@ -105,5 +110,6 @@ pub fn run(command: PCommand, pretty: bool) -> Result<()> { PCommand::Project { target } => crate::cmd_project::run(target), PCommand::Incept { target } => crate::cmd_incept::run(target), PCommand::Track { op } => crate::cmd_track::run(op, pretty), + PCommand::Query { op } => crate::cmd_p_query::run(op, pretty), } } diff --git a/crates/path-cli/src/cmd_p_query.rs b/crates/path-cli/src/cmd_p_query.rs new file mode 100644 index 00000000..d625199a --- /dev/null +++ b/crates/path-cli/src/cmd_p_query.rs @@ -0,0 +1,117 @@ +//! `path p query` — low-level graph traversal on a single document. +//! +//! The porcelain `path query` (jaq over the whole cache) covers filtering, +//! dead-end detection, and aggregation. What stays here is the one query that +//! isn't a per-step predicate: `ancestors`, which walks the parent chain from +//! a step. (The former `dead-ends` and `filter` subcommands are now jaq forms: +//! `path query 'map(select(.dead_end))'` and +//! `path query 'map(select(.step.actor | startswith("agent:")))'`.) + +use anyhow::Result; +use clap::Subcommand; +use std::path::PathBuf; +use toolpath::v1::{Graph, PathOrRef, query}; + +#[derive(Subcommand, Debug)] +pub enum PQueryOp { + /// Walk the parent chain from a step + Ancestors { + /// Input file + #[arg(short, long)] + input: PathBuf, + + /// Step ID to trace from + #[arg(long)] + step_id: String, + }, +} + +pub fn run(op: PQueryOp, pretty: bool) -> Result<()> { + match op { + PQueryOp::Ancestors { input, step_id } => run_ancestors(input, step_id, pretty), + } +} + +/// Returns the steps from the graph's first inline path. +fn extract_steps(doc: &Graph) -> &[toolpath::v1::Step] { + for entry in &doc.paths { + if let PathOrRef::Path(path) = entry { + return path.steps.as_slice(); + } + } + &[] +} + +fn run_ancestors(input: PathBuf, step_id: String, pretty: bool) -> Result<()> { + let doc = crate::io::read_document_auto(&input)?; + let steps = extract_steps(&doc); + let ancestor_ids = query::ancestors(steps, &step_id); + + let ancestor_steps: Vec<&toolpath::v1::Step> = steps + .iter() + .filter(|s| ancestor_ids.contains(&s.step.id)) + .collect(); + + let json = if pretty { + serde_json::to_string_pretty(&ancestor_steps)? + } else { + serde_json::to_string(&ancestor_steps)? + }; + println!("{json}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use toolpath::v1::{Base, Path, PathIdentity, Step}; + + 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") + .with_parent("s1") + .with_raw_change("src/lib.rs", "@@"); + let s3 = Step::new("s3", "human:alex", "2026-01-01T12:00:00Z") + .with_parent("s2") + .with_raw_change("src/main.rs", "@@"); + Graph::from_path(Path { + path: PathIdentity { + id: "p1".into(), + base: Some(Base::vcs("github:org/repo", "abc")), + head: "s3".into(), + graph_ref: None, + }, + steps: vec![s1, s2, s3], + meta: None, + }) + } + + 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(); + f + } + + #[test] + fn extract_steps_reads_first_inline_path() { + let doc = make_path_doc(); + assert_eq!(extract_steps(&doc).len(), 3); + } + + #[test] + fn extract_steps_empty_graph() { + let doc = Graph::new("g1"); + assert!(extract_steps(&doc).is_empty()); + } + + #[test] + fn ancestors_runs() { + let doc = make_path_doc(); + let f = write_temp_doc(&doc); + assert!(run_ancestors(f.path().to_path_buf(), "s3".to_string(), false).is_ok()); + assert!(run_ancestors(f.path().to_path_buf(), "s3".to_string(), true).is_ok()); + } +} diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index fdb1e201..bd00061d 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -1,314 +1,105 @@ +//! `path query` — load every cached step into one JSON array and transform it +//! with a jaq (jq) filter. +//! +//! This is a thin clap layer over [`crate::query`]; the scope flags choose +//! *which* documents load and the filter does the rest. See the design at +//! `docs/superpowers/specs/2026-06-22-path-query-command-design.md` and +//! `path kind` for the per-step field reference. + use anyhow::Result; -use clap::Subcommand; +use clap::Parser; +use std::io::IsTerminal; use std::path::PathBuf; -use toolpath::v1::{Graph, PathOrRef, query}; - -#[derive(Subcommand, Debug)] -pub enum QueryOp { - /// Walk the parent chain from a step - Ancestors { - /// Input file - #[arg(short, long)] - input: PathBuf, - - /// Step ID to trace from - #[arg(long)] - step_id: String, - }, - /// Find steps not on the path to head - DeadEnds { - /// Input file - #[arg(short, long)] - input: PathBuf, - }, - /// Filter steps by criteria - Filter { - /// Input file - #[arg(short, long)] - input: PathBuf, - - /// Actor prefix (e.g., "human:", "agent:claude") - #[arg(long)] - actor: Option, - - /// Artifact path - #[arg(long)] - artifact: Option, - - /// Start time (ISO 8601) - #[arg(long)] - after: Option, - /// End time (ISO 8601) - #[arg(long)] - before: Option, - }, +use crate::query::Scope; + +/// Each array element is a Toolpath step (`step`/`change`/`meta` verbatim) +/// wrapped with three keys: `cache_id`, `path` (the parent path's `id`/`base`/ +/// `meta`), and `dead_end` (whether the step sits off the head's ancestry). +/// Reach a step's fields with the usual jq paths, e.g. +/// `.change[].structural.token_usage`. Run `path kind` for the field reference. +#[derive(Parser, Debug)] +#[command(after_long_help = WRAPPER_HELP)] +pub struct QueryArgs { + /// jaq (jq) filter to run over the scoped step array. Omit to emit the + /// array unchanged (equivalent to the `.` filter). + filter: Option, + + /// Select cached files by source prefix + /// (claude/gemini/codex/opencode/cursor/pi/git/github). + #[arg(long)] + source: Option, + + /// Load a specific cached document by id (repeatable). + #[arg(long = "id")] + ids: Vec, + + /// Load an off-cache document (`-` for stdin; repeatable). + #[arg(long)] + input: Vec, + + /// Keep only paths whose base resolves to this project directory. + #[arg(long)] + project: Option, + + /// Keep only paths whose meta.kind matches this selector + /// (semver prefix, e.g. `agent-coding-session` or `…/v1.0`). + #[arg(long)] + kind: Option, + + /// Force compact (single-line) JSON output. + #[arg(short = 'c', long)] + compact: bool, + + /// Output raw strings without JSON quotes/escaping (like `jq -r`). + /// Applies only to string results; non-strings still print as JSON. + /// Handy for piping a column of ids/paths into another command, or + /// reading text/diff content. Composes with `-c`. + #[arg(short = 'r', long)] + raw: bool, } -pub fn run(op: QueryOp, pretty: bool) -> Result<()> { - match op { - QueryOp::Ancestors { input, step_id } => run_ancestors(input, step_id, pretty), - QueryOp::DeadEnds { input } => run_dead_ends(input, pretty), - QueryOp::Filter { - input, - actor, - artifact, - after, - before, - } => run_filter(input, actor, artifact, after, before, pretty), - } -} - -fn read_doc(path: &std::path::Path) -> Result { - crate::io::read_document_auto(path) -} - -/// 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())); - } - } - (&[], None) -} - -fn print_steps(steps: &[&toolpath::v1::Step], pretty: bool) -> Result<()> { - let json = if pretty { - serde_json::to_string_pretty(&steps)? - } else { - serde_json::to_string(&steps)? +const WRAPPER_HELP: &str = "\ +Each array element wraps a Toolpath step: + + { + \"cache_id\": \"claude-abc123\", + \"path\": { \"id\": …, \"base\": …, \"meta\": { \"kind\": …, \"source\": … } }, + \"step\": { \"id\": …, \"parents\": [...], \"actor\": …, \"timestamp\": … }, + \"change\": { \"\": { \"raw\": …, \"structural\": … } }, + \"meta\": { … }, + \"dead_end\": false + } + +The fields under `change[].structural` are defined by the path's kind; +run `path kind ` for the schema, or `path query --kind … '.[0]'` to +read a sample. Identity is the triple (cache_id, path.id, step.id). + +A real cache mixes provider and tool versions, so a field's shape can vary +(e.g. `tool_uses[]` is sometimes an object, sometimes a bare name string). +Guard element access with `?` (`.name?`) and `// empty` so one odd value +doesn't abort the run; sample with `'.[0]'` when unsure. + +Examples: + path query 'map(select(any(.. | strings; test(\"RefCell\"))))' + path query 'map(select(any(.change | keys[]; endswith(\"cmd_resume.rs\"))))' + path query --source claude 'map(select(any(.change[].structural.tool_uses[]?; .name? == \"Bash\" and .result.is_error? == true)))' + path query 'group_by(.path.meta.source) | map({source: .[0].path.meta.source, steps: length})' + path query -r '.[].cache_id' | sort -u # raw ids, pipeable to xargs/grep + path query -r '.[0].change[].structural.text' # read a turn's text, unescaped"; + +pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { + let scope = Scope { + source: args.source, + ids: args.ids, + inputs: args.input, + project: args.project, + kind: args.kind, }; - println!("{}", json); - Ok(()) -} - -fn run_ancestors(input: PathBuf, step_id: String, pretty: bool) -> Result<()> { - let doc = read_doc(&input)?; - let (steps, _) = extract_steps(&doc); - let ancestor_ids = query::ancestors(steps, &step_id); - - let ancestor_steps: Vec<&toolpath::v1::Step> = steps - .iter() - .filter(|s| ancestor_ids.contains(&s.step.id)) - .collect(); - - print_steps(&ancestor_steps, pretty) -} - -fn run_dead_ends(input: PathBuf, pretty: bool) -> Result<()> { - let doc = read_doc(&input)?; - let (steps, head) = extract_steps(&doc); - let head = head.ok_or_else(|| anyhow::anyhow!("Graph has no head step"))?; - - let dead = query::dead_ends(steps, head); - print_steps(&dead, pretty) -} - -fn run_filter( - input: PathBuf, - actor: Option, - artifact: Option, - after: Option, - before: Option, - pretty: bool, -) -> Result<()> { - let doc = read_doc(&input)?; - let (steps, _) = extract_steps(&doc); - - let mut result: Vec<&toolpath::v1::Step> = steps.iter().collect(); - - if let Some(ref actor_prefix) = actor { - let filtered = query::filter_by_actor(steps, actor_prefix); - let ids: std::collections::HashSet<&str> = - filtered.iter().map(|s| s.step.id.as_str()).collect(); - result.retain(|s| ids.contains(s.step.id.as_str())); - } - - if let Some(ref art) = artifact { - let filtered = query::filter_by_artifact(steps, art); - let ids: std::collections::HashSet<&str> = - filtered.iter().map(|s| s.step.id.as_str()).collect(); - result.retain(|s| ids.contains(s.step.id.as_str())); - } - - if after.is_some() || before.is_some() { - let start = after.as_deref().unwrap_or(""); - let end = before.as_deref().unwrap_or("9999-12-31T23:59:59Z"); - let filtered = query::filter_by_time_range(steps, start, end); - let ids: std::collections::HashSet<&str> = - filtered.iter().map(|s| s.step.id.as_str()).collect(); - result.retain(|s| ids.contains(s.step.id.as_str())); - } - - print_steps(&result, pretty) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use toolpath::v1::{Base, Path, PathIdentity, Step}; - - 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") - .with_parent("s1") - .with_raw_change("src/lib.rs", "@@"); - let s2a = Step::new("s2a", "agent:claude", "2026-01-01T11:30:00Z") - .with_parent("s1") - .with_raw_change("src/main.rs", "@@"); - let s3 = Step::new("s3", "human:alex", "2026-01-01T12:00:00Z") - .with_parent("s2") - .with_raw_change("src/main.rs", "@@"); - Graph::from_path(Path { - path: PathIdentity { - id: "p1".into(), - base: Some(Base::vcs("github:org/repo", "abc")), - head: "s3".into(), - graph_ref: None, - }, - steps: vec![s1, s2, s2a, s3], - meta: None, - }) - } - - 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(); - f - } - - #[test] - fn test_extract_steps_from_single_path_graph() { - let doc = make_path_doc(); - let (steps, head) = extract_steps(&doc); - assert_eq!(steps.len(), 4); - assert_eq!(head, Some("s3")); - } - - #[test] - 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_run_ancestors() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_ancestors(f.path().to_path_buf(), "s3".to_string(), false); - assert!(result.is_ok()); - } - - #[test] - fn test_run_dead_ends() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_dead_ends(f.path().to_path_buf(), false); - assert!(result.is_ok()); - } - - #[test] - fn test_run_filter_by_actor() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_filter( - f.path().to_path_buf(), - Some("human:".to_string()), - None, - None, - None, - false, - ); - assert!(result.is_ok()); - } - - #[test] - fn test_run_filter_by_artifact() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_filter( - f.path().to_path_buf(), - None, - Some("src/main.rs".to_string()), - None, - None, - false, - ); - assert!(result.is_ok()); - } - - #[test] - fn test_run_filter_by_time_range() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_filter( - f.path().to_path_buf(), - None, - None, - Some("2026-01-01T10:30:00Z".to_string()), - Some("2026-01-01T11:30:00Z".to_string()), - false, - ); - assert!(result.is_ok()); - } - - #[test] - fn test_run_filter_pretty() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_filter(f.path().to_path_buf(), None, None, None, None, true); - assert!(result.is_ok()); - } - - #[test] - fn test_run_filter_after_only() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_filter( - f.path().to_path_buf(), - None, - None, - Some("2026-01-01T11:00:00Z".to_string()), - None, - false, - ); - assert!(result.is_ok()); - } - - #[test] - 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); - // Empty graphs have no head step. - assert!(result.is_err()); - } - - #[test] - fn test_run_ancestors_pretty() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_ancestors(f.path().to_path_buf(), "s3".to_string(), true); - assert!(result.is_ok()); - } - #[test] - fn test_run_dead_ends_pretty() { - let doc = make_path_doc(); - let f = write_temp_doc(&doc); - let result = run_dead_ends(f.path().to_path_buf(), true); - assert!(result.is_ok()); - } + // Output policy mirrors jq: compact when forced with `-c` or when piped; + // pretty on a TTY or when the global `--pretty` flag is set. + let compact = args.compact || (!pretty && !std::io::stdout().is_terminal()); - #[test] - fn test_read_doc_invalid_path() { - let result = read_doc(&PathBuf::from("/nonexistent/file.json")); - assert!(result.is_err()); - } + crate::query::run(&scope, args.filter.as_deref(), compact, args.raw) } diff --git a/crates/path-cli/src/kinds.rs b/crates/path-cli/src/kinds.rs new file mode 100644 index 00000000..21a06e0e --- /dev/null +++ b/crates/path-cli/src/kinds.rs @@ -0,0 +1,234 @@ +//! Bundled document-kind specs and semver-prefix matching. +//! +//! The binary ships a copy of every kind spec it knows about so that +//! `path kind` and `path query --kind` work offline. [`BUNDLED_KINDS`] is the +//! single source of truth for which `(name, version)` specs are baked in; +//! [`crate::schema`] (kind-aware validation) and the query layer both read it. +//! +//! A `meta.kind` value is a semver-versioned URI of the form +//! `…/kinds//v..`. A [`KindSelector`] matches a +//! *prefix* of `(name, major, minor, patch)`: a bare name matches any version, +//! `v1` matches `v1.*.*`, `v1.0` matches `v1.0.*`, and a full triple matches +//! exactly. Matching compares parsed integer tuples, so `v1` matches `v1.9.0` +//! but keeps `v10.0.0` separate. + +/// A kind spec compiled into the binary. +pub struct BundledKind { + /// Kind name, e.g. `agent-coding-session`. + pub name: &'static str, + /// Version segment, e.g. `v1.1.0`. + pub version: &'static str, + /// Full `meta.kind` URI this spec answers to. + pub uri: &'static str, + /// The bundled `schema.json` source. + pub schema: &'static str, +} + +/// Every kind spec baked into the binary, oldest version first. +/// +/// The `schema.json` files live at `crates/path-cli/kinds///` +/// and publish under `https://toolpath.net/kinds/`. +pub const BUNDLED_KINDS: &[BundledKind] = &[ + BundledKind { + name: "agent-coding-session", + version: "v1.0.0", + uri: "https://toolpath.net/kinds/agent-coding-session/v1.0.0", + schema: include_str!("../kinds/agent-coding-session/v1.0.0/schema.json"), + }, + BundledKind { + name: "agent-coding-session", + version: "v1.1.0", + uri: "https://toolpath.net/kinds/agent-coding-session/v1.1.0", + schema: include_str!("../kinds/agent-coding-session/v1.1.0/schema.json"), + }, +]; + +/// A parsed `(major, minor, patch)` version triple. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, +} + +fn parse_version(s: &str) -> Option { + let s = s.strip_prefix('v').unwrap_or(s); + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some(Version { + major, + minor, + patch, + }) +} + +/// Parse a full `meta.kind` URI into its `(name, version)`. +/// +/// Returns `None` if it doesn't look like `…//v..`. +pub fn parse_kind_uri(uri: &str) -> Option<(String, Version)> { + let tail = uri.rsplit_once("/kinds/").map_or(uri, |(_, t)| t); + let tail = tail.trim_end_matches('/'); + let (name, ver) = tail.rsplit_once('/')?; + let version = parse_version(ver)?; + Some((name.to_string(), version)) +} + +/// A `--kind` / `path kind` selector: a name plus an optional version prefix. +#[derive(Debug, Clone)] +pub struct KindSelector { + name: String, + major: Option, + minor: Option, + patch: Option, +} + +/// Parse a selector. Accepts a bare name (`agent-coding-session`), a +/// name + version prefix (`agent-coding-session/v1`, `.../v1.0`, +/// `.../v1.0.0`, with the `v` optional), or a full `meta.kind` URI. +pub fn parse_kind_selector(s: &str) -> KindSelector { + let tail = s.rsplit_once("/kinds/").map_or(s, |(_, t)| t); + let tail = tail.trim_end_matches('/'); + match tail.split_once('/') { + None => KindSelector { + name: tail.to_string(), + major: None, + minor: None, + patch: None, + }, + Some((name, ver)) => { + let ver = ver.strip_prefix('v').unwrap_or(ver); + let mut it = ver.split('.'); + KindSelector { + name: name.to_string(), + major: it.next().and_then(|x| x.parse().ok()), + minor: it.next().and_then(|x| x.parse().ok()), + patch: it.next().and_then(|x| x.parse().ok()), + } + } + } +} + +impl KindSelector { + /// Whether this selector matches a parsed `(name, version)`. + pub fn matches(&self, name: &str, v: Version) -> bool { + self.name == name + && self.major.is_none_or(|m| m == v.major) + && self.minor.is_none_or(|m| m == v.minor) + && self.patch.is_none_or(|p| p == v.patch) + } + + /// Whether this selector matches a `meta.kind` URI. A URI that doesn't + /// parse as a versioned kind never matches. + pub fn matches_uri(&self, uri: &str) -> bool { + parse_kind_uri(uri).is_some_and(|(name, v)| self.matches(&name, v)) + } +} + +/// Resolve a selector to the newest bundled kind spec it matches. +pub fn resolve(selector: &str) -> Option<&'static BundledKind> { + let sel = parse_kind_selector(selector); + BUNDLED_KINDS + .iter() + .filter(|k| parse_kind_uri(k.uri).is_some_and(|(name, v)| sel.matches(&name, v))) + .max_by_key(|k| parse_kind_uri(k.uri).map(|(_, v)| v)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(major: u64, minor: u64, patch: u64) -> Version { + Version { + major, + minor, + patch, + } + } + + #[test] + fn bundled_kind_uris_parse() { + for k in BUNDLED_KINDS { + let (name, _) = parse_kind_uri(k.uri).expect("bundled URI parses"); + assert_eq!(name, k.name); + } + } + + #[test] + fn parse_uri_extracts_name_and_version() { + let (name, ver) = + parse_kind_uri("https://toolpath.net/kinds/agent-coding-session/v1.1.0").unwrap(); + assert_eq!(name, "agent-coding-session"); + assert_eq!(ver, v(1, 1, 0)); + } + + #[test] + fn parse_uri_rejects_unversioned() { + assert!(parse_kind_uri("https://toolpath.net/kinds/agent-coding-session").is_none()); + } + + #[test] + fn bare_name_matches_any_version() { + let sel = parse_kind_selector("agent-coding-session"); + assert!(sel.matches("agent-coding-session", v(1, 0, 0))); + assert!(sel.matches("agent-coding-session", v(2, 5, 9))); + assert!(!sel.matches("other-kind", v(1, 0, 0))); + } + + #[test] + fn major_prefix_keeps_v1_and_v10_distinct() { + let sel = parse_kind_selector("agent-coding-session/v1"); + assert!(sel.matches("agent-coding-session", v(1, 0, 0))); + assert!(sel.matches("agent-coding-session", v(1, 9, 0))); + assert!(!sel.matches("agent-coding-session", v(10, 0, 0))); + } + + #[test] + fn minor_prefix_pins_major_minor() { + let sel = parse_kind_selector("agent-coding-session/v1.0"); + assert!(sel.matches("agent-coding-session", v(1, 0, 0))); + assert!(sel.matches("agent-coding-session", v(1, 0, 9))); + assert!(!sel.matches("agent-coding-session", v(1, 1, 0))); + } + + #[test] + fn full_triple_matches_exactly() { + let sel = parse_kind_selector("agent-coding-session/v1.0.0"); + assert!(sel.matches("agent-coding-session", v(1, 0, 0))); + assert!(!sel.matches("agent-coding-session", v(1, 0, 1))); + } + + #[test] + fn v_prefix_is_optional() { + let sel = parse_kind_selector("agent-coding-session/1.0"); + assert!(sel.matches("agent-coding-session", v(1, 0, 0))); + } + + #[test] + fn full_uri_selector_matches_exactly() { + let sel = parse_kind_selector("https://toolpath.net/kinds/agent-coding-session/v1.0.0"); + assert!(sel.matches("agent-coding-session", v(1, 0, 0))); + assert!(!sel.matches("agent-coding-session", v(1, 1, 0))); + } + + #[test] + fn resolve_picks_newest_for_bare_name() { + let k = resolve("agent-coding-session").expect("bundled"); + assert_eq!(k.version, "v1.1.0"); + } + + #[test] + fn resolve_pins_exact_version() { + let k = resolve("agent-coding-session/v1.0.0").expect("bundled"); + assert_eq!(k.version, "v1.0.0"); + } + + #[test] + fn resolve_unknown_is_none() { + assert!(resolve("no-such-kind").is_none()); + } +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 0189bb04..07f52409 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -6,9 +6,11 @@ mod cmd_export; mod cmd_haiku; mod cmd_import; mod cmd_incept; +mod cmd_kind; mod cmd_list; mod cmd_merge; mod cmd_p; +mod cmd_p_query; #[cfg(not(target_os = "emscripten"))] mod cmd_pathbase; mod cmd_project; @@ -26,6 +28,8 @@ mod config; #[cfg(not(target_os = "emscripten"))] mod fuzzy; mod io; +mod kinds; +mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; @@ -82,10 +86,17 @@ enum Commands { #[command(flatten)] args: cmd_resume::ResumeArgs, }, - /// Query Toolpath documents + /// Query the local cache: load every step into one JSON array and + /// transform it with a jaq (jq) filter Query { - #[command(subcommand)] - op: cmd_query::QueryOp, + #[command(flatten)] + args: cmd_query::QueryArgs, + }, + /// List bundled document kinds, or print a kind's schema (the field + /// reference for `path query`) + Kind { + #[command(flatten)] + args: cmd_kind::KindArgs, }, /// Manage Pathbase credentials for trace uploads #[cfg(not(target_os = "emscripten"))] @@ -121,7 +132,8 @@ pub fn run() -> Result<()> { Commands::Share { args } => cmd_share::run(args), #[cfg(not(target_os = "emscripten"))] Commands::Resume { args } => cmd_resume::run(args), - Commands::Query { op } => cmd_query::run(op, cli.pretty), + Commands::Query { args } => cmd_query::run(args, cli.pretty), + Commands::Kind { args } => cmd_kind::run(args), #[cfg(not(target_os = "emscripten"))] Commands::Auth { op } => cmd_auth::run(op), Commands::P { command } => cmd_p::run(command, cli.pretty), diff --git a/crates/path-cli/src/query/filter.rs b/crates/path-cli/src/query/filter.rs new file mode 100644 index 00000000..36a36313 --- /dev/null +++ b/crates/path-cli/src/query/filter.rs @@ -0,0 +1,184 @@ +//! In-process jaq (pure-Rust jq) execution for `path query`. +//! +//! The scoped step array is handed to jaq as a single input value; the filter +//! does all matching, projection, ranking, and aggregation. Output mirrors jq: +//! each value the filter yields is printed on its own line, pretty-printed by +//! default and compact under `--compact` (or when stdout is not a TTY). With +//! `--raw`, string results print unquoted (like `jq -r`). + +use anyhow::{Result, anyhow}; +use std::io::Write; + +use jaq_core::load::{Arena, File, Loader}; +use jaq_core::{Compiler, Ctx, Vars, data, unwrap_valr}; +use jaq_json::Val; + +/// Compile `code` and run it over `input`, printing each output value. +/// +/// `raw` mirrors `jq -r`: string results print without JSON quoting or +/// escaping; every other value still prints as JSON. +pub fn run(input: &serde_json::Value, code: &str, compact: bool, raw: bool) -> Result<()> { + // serde_json::Value → jaq Val via a JSON round-trip. The array is one we + // just built, so parsing it back can't realistically fail. + let bytes = serde_json::to_vec(input)?; + let val = jaq_json::read::parse_single(&bytes) + .map_err(|e| anyhow!("internal: could not load step array into jaq: {e}"))?; + + let program = File { code, path: () }; + let defs = jaq_core::defs() + .chain(jaq_std::defs()) + .chain(jaq_json::defs()); + let funs = jaq_core::funs() + .chain(jaq_std::funs()) + .chain(jaq_json::funs()); + + let loader = Loader::new(defs); + let arena = Arena::default(); + let modules = loader + .load(&arena, program) + .map_err(|errs| format_load_errors(code, errs))?; + + let filter = Compiler::default() + .with_funs(funs) + .compile(modules) + .map_err(|errs| format_compile_errors(code, errs))?; + + // jq parity: compact is `{"a":1}`; pretty is 2-space indented with a + // space after each colon. + let pp = jaq_json::write::Pp { + indent: (!compact).then(|| " ".to_string()), + sep_space: !compact, + ..Default::default() + }; + + let ctx = Ctx::>::new(&filter.lut, Vars::new([])); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for res in filter.id.run((ctx, val)).map(unwrap_valr) { + let value = res.map_err(|e| anyhow!("query filter error: {e}"))?; + // `--raw`: print string values as their bytes, no quotes/escaping. + // Both jaq string variants (UTF-8 `TStr`, byte `BStr`) hold `Bytes`, + // which derefs to `&[u8]`. Non-strings fall through to JSON. + if raw && let Val::TStr(b) | Val::BStr(b) = &value { + let bytes: &[u8] = b; + out.write_all(bytes)?; + } else { + jaq_json::write::write(&mut out, &pp, 0, &value)?; + } + out.write_all(b"\n")?; + } + Ok(()) +} + +/// First non-empty line of `s`, truncated, for pointing at a syntax error. +fn snippet(s: &str) -> String { + let line = s.trim().lines().next().unwrap_or("").trim(); + if line.chars().count() > 30 { + format!("{}…", line.chars().take(30).collect::()) + } else { + line.to_string() + } +} + +fn format_load_errors(code: &str, errs: jaq_core::load::Errors<&str, ()>) -> anyhow::Error { + let mut msgs = Vec::new(); + for (_file, err) in errs { + match err { + jaq_core::load::Error::Io(es) => { + msgs.extend(es.into_iter().map(|(_, e)| format!("io error: {e}"))); + } + jaq_core::load::Error::Lex(es) => { + msgs.extend(es.into_iter().map(|(expect, at)| { + format!("expected {} at `{}`", expect.as_str(), snippet(at)) + })); + } + jaq_core::load::Error::Parse(es) => { + msgs.extend(es.into_iter().map(|(expect, at)| { + format!("expected {} at `{}`", expect.as_str(), snippet(at)) + })); + } + } + } + if msgs.is_empty() { + msgs.push("syntax error".to_string()); + } + anyhow!("invalid jq filter `{code}`:\n {}", msgs.join("\n ")) +} + +fn format_compile_errors(code: &str, errs: jaq_core::compile::Errors<&str, ()>) -> anyhow::Error { + let mut msgs = Vec::new(); + for (_file, es) in errs { + for (name, undefined) in es { + msgs.push(format!("undefined {}: {name}", undefined.as_str())); + } + } + if msgs.is_empty() { + msgs.push("compile error".to_string()); + } + anyhow!( + "could not compile jq filter `{code}`:\n {}", + msgs.join("\n ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn array() -> serde_json::Value { + json!([ + {"cache_id": "a", "step": {"id": "s1", "actor": "human:alex"}, "tokens": 10}, + {"cache_id": "b", "step": {"id": "s2", "actor": "agent:claude"}, "tokens": 90}, + ]) + } + + /// Capture-free smoke test: a valid filter compiles and runs without error. + #[test] + fn identity_filter_runs() { + run(&array(), ".", true, false).unwrap(); + } + + #[test] + fn select_and_aggregate_run() { + run(&array(), "map(select(.tokens > 50))", true, false).unwrap(); + run(&array(), "[.[].tokens] | add", true, false).unwrap(); + run(&array(), "sort_by(-.tokens) | .[0].cache_id", false, false).unwrap(); + } + + #[test] + fn regex_test_is_available() { + // `test` requires the regex feature; this would error if it weren't on. + run( + &array(), + r#"map(select(.step.actor | test("claude")))"#, + true, + false, + ) + .unwrap(); + } + + #[test] + fn raw_mode_runs_for_strings_and_nonstrings() { + // String, non-string, and a stream that mixes both — none should error. + run(&array(), ".[].cache_id", true, true).unwrap(); + run(&array(), ".[].tokens", true, true).unwrap(); + run(&array(), ".[0]", true, true).unwrap(); + } + + #[test] + fn syntax_error_is_reported() { + let err = run(&array(), "map(select(", true, false).unwrap_err(); + assert!(err.to_string().contains("jq filter"), "{err}"); + } + + #[test] + fn unknown_function_is_reported() { + let err = run(&array(), "no_such_function", true, false).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("compile") || msg.contains("undefined"), + "{msg}" + ); + } +} diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs new file mode 100644 index 00000000..e03695a6 --- /dev/null +++ b/crates/path-cli/src/query/mod.rs @@ -0,0 +1,394 @@ +//! Engine behind `path query`: load cached (and off-cache) documents, wrap +//! each step in its source context, and run a jaq (jq) filter over the whole +//! array. +//! +//! The model is one idea: load every scoped step into a single JSON array and +//! transform it with a jaq filter. Selection, projection, ranking, grouping, +//! and top-N are all expressed in the filter; this module only decides *which* +//! documents load and hands jaq the array. + +mod filter; + +use anyhow::{Context, Result}; +use std::collections::HashSet; +use std::io::Read; +use std::path::{Path as FsPath, PathBuf}; + +use toolpath::v1::{Graph, Path, PathOrRef, query}; + +use crate::kinds::{self, KindSelector}; + +/// What to load and how to scope it. Mirrors the `path query` scope flags. +pub struct Scope { + /// `--source`: keep cache entries whose id starts with `-`. + pub source: Option, + /// `--id`: load only these cache ids (repeatable). + pub ids: Vec, + /// `--input`: off-cache files to load (`-` for stdin, repeatable). + pub inputs: Vec, + /// `--project`: keep only paths whose `base` resolves to this directory. + pub project: Option, + /// `--kind`: keep only paths whose `meta.kind` matches this selector. + pub kind: Option, +} + +/// Load the scoped step array, run `filter` over it, and print the result. +/// +/// `filter` is jaq source; `None` is treated as `.` (emit the array verbatim). +/// `compact` forces single-line JSON; otherwise output is pretty-printed. +/// `raw` prints string results without JSON quoting (like `jq -r`). +pub fn run(scope: &Scope, filter: Option<&str>, compact: bool, raw: bool) -> Result<()> { + let elements = load_steps(scope)?; + let array = serde_json::Value::Array(elements); + filter::run(&array, filter.unwrap_or("."), compact, raw) +} + +/// Where a document came from, and the `cache_id` to stamp on its steps. +struct DocSource { + cache_id: String, + location: SourceLoc, +} + +enum SourceLoc { + File(PathBuf), + Stdin, +} + +impl DocSource { + fn label(&self) -> String { + match &self.location { + SourceLoc::File(p) => p.display().to_string(), + SourceLoc::Stdin => "".to_string(), + } + } +} + +/// Build the wrapped step array from every selected, scoped document. +fn load_steps(scope: &Scope) -> Result> { + let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector); + let project = scope.project.as_deref().map(canonicalize_or_self); + + let mut out = Vec::new(); + for src in select_files(scope)? { + let graph = match read_source(&src) { + Ok(g) => g, + Err(e) => { + eprintln!("warning: skipping {}: {e:#}", src.label()); + continue; + } + }; + wrap_graph( + &src, + &graph, + kind_sel.as_ref(), + project.as_deref(), + &mut out, + ); + } + Ok(out) +} + +/// Resolve the scope's file-selection flags to a deterministic list of +/// documents to load. +/// +/// The cache is read when no file selector restricts to off-cache inputs: +/// that is, when `--source`/`--id` is present, or when no `--input` is given +/// at all (the default whole-cache scan). `--input` files are appended in the +/// order given. +fn select_files(scope: &Scope) -> Result> { + let mut sources = Vec::new(); + + let restrict = scope.source.is_some() || !scope.ids.is_empty(); + let load_cache = restrict || scope.inputs.is_empty(); + if load_cache { + let id_set: Option> = if scope.ids.is_empty() { + None + } else { + Some(scope.ids.iter().map(String::as_str).collect()) + }; + let prefix = scope.source.as_ref().map(|s| format!("{s}-")); + for entry in crate::cmd_cache::list_cached()? { + if let Some(ids) = &id_set + && !ids.contains(entry.id.as_str()) + { + continue; + } + if let Some(p) = &prefix + && !entry.id.starts_with(p.as_str()) + { + continue; + } + sources.push(DocSource { + cache_id: entry.id, + location: SourceLoc::File(entry.path), + }); + } + } + + for inp in &scope.inputs { + if inp == "-" { + sources.push(DocSource { + cache_id: "stdin".to_string(), + location: SourceLoc::Stdin, + }); + } else { + let p = PathBuf::from(inp); + let id = p + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(inp) + .to_string(); + sources.push(DocSource { + cache_id: id, + location: SourceLoc::File(p), + }); + } + } + + Ok(sources) +} + +fn read_source(src: &DocSource) -> Result { + match &src.location { + SourceLoc::File(p) => crate::io::read_document_auto(p), + SourceLoc::Stdin => { + let mut s = String::new(); + std::io::stdin() + .read_to_string(&mut s) + .context("read stdin")?; + Graph::from_json(&s).context("parse stdin as toolpath JSON") + } + } +} + +/// Walk a graph's inline paths, apply content scoping, and wrap surviving +/// steps into `out`. +fn wrap_graph( + src: &DocSource, + graph: &Graph, + kind_sel: Option<&KindSelector>, + project: Option<&FsPath>, + out: &mut Vec, +) { + for entry in &graph.paths { + let PathOrRef::Path(path) = entry else { + continue; + }; + + if let Some(sel) = kind_sel { + let kind = path.meta.as_ref().and_then(|m| m.kind.as_deref()); + if !kind.is_some_and(|k| sel.matches_uri(k)) { + continue; + } + } + if let Some(proj) = project + && !path_matches_project(path, proj) + { + continue; + } + + wrap_path(src, path, out); + } +} + +/// Wrap every step of one path, computing the dead-end set once. +fn wrap_path(src: &DocSource, path: &Path, out: &mut Vec) { + let dead: HashSet<&str> = query::dead_ends(&path.steps, &path.path.head) + .into_iter() + .map(|s| s.step.id.as_str()) + .collect(); + + let path_ctx = path_context(path); + + for step in &path.steps { + // A Step serializes to `{"step": …, "change": …, "meta"?: …}`; we add + // the three wrapper keys (`cache_id`, `path`, `dead_end`) alongside. + let serde_json::Value::Object(mut obj) = serde_json::to_value(step).unwrap_or_default() + else { + continue; + }; + obj.insert( + "cache_id".to_string(), + serde_json::Value::String(src.cache_id.clone()), + ); + obj.insert("path".to_string(), path_ctx.clone()); + obj.insert( + "dead_end".to_string(), + serde_json::Value::Bool(dead.contains(step.step.id.as_str())), + ); + out.push(serde_json::Value::Object(obj)); + } +} + +/// The `path` context attached to every step: the parent path's `id`, `base`, +/// and `meta`. +fn path_context(path: &Path) -> serde_json::Value { + let mut m = serde_json::Map::new(); + m.insert( + "id".to_string(), + serde_json::Value::String(path.path.id.clone()), + ); + if let Some(base) = &path.path.base + && let Ok(v) = serde_json::to_value(base) + { + m.insert("base".to_string(), v); + } + if let Some(meta) = &path.meta + && let Ok(v) = serde_json::to_value(meta) + { + m.insert("meta".to_string(), v); + } + serde_json::Value::Object(m) +} + +/// Whether a path's `base` resolves to `project` (a canonicalized directory). +/// Only `file://` bases can match; VCS bases (`github:…`) never do. +fn path_matches_project(path: &Path, project: &FsPath) -> bool { + let Some(base) = &path.path.base else { + return false; + }; + let Some(fs) = base.uri.strip_prefix("file://") else { + return false; + }; + canonicalize_or_self(FsPath::new(fs)) == project +} + +fn canonicalize_or_self(p: &FsPath) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + use toolpath::v1::{Base, Graph, Path, PathIdentity, PathMeta, Step}; + + fn doc_src(id: &str) -> DocSource { + DocSource { + cache_id: id.to_string(), + location: SourceLoc::Stdin, + } + } + + /// A path with a fork: s1 → {s2 → s3 (head), s2a (dead end)}. + fn forked_path() -> Path { + 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") + .with_parent("s1") + .with_raw_change("src/lib.rs", "@@"); + let s2a = Step::new("s2a", "agent:claude", "2026-01-01T11:30:00Z") + .with_parent("s1") + .with_raw_change("src/dead.rs", "@@"); + let s3 = Step::new("s3", "human:alex", "2026-01-01T12:00:00Z") + .with_parent("s2") + .with_raw_change("src/main.rs", "@@"); + Path { + path: PathIdentity { + id: "p1".into(), + base: Some(Base::vcs("file:///work/repo", "abc")), + head: "s3".into(), + graph_ref: None, + }, + steps: vec![s1, s2, s2a, s3], + meta: Some(PathMeta { + kind: Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION.to_string()), + source: Some("claude".to_string()), + ..Default::default() + }), + } + } + + #[test] + fn wraps_step_verbatim_with_context() { + let path = forked_path(); + let mut out = Vec::new(); + wrap_path(&doc_src("claude-abc"), &path, &mut out); + + assert_eq!(out.len(), 4); + let first = &out[0]; + // Wrapper keys present. + assert_eq!(first["cache_id"], "claude-abc"); + assert_eq!(first["path"]["id"], "p1"); + assert_eq!(first["path"]["meta"]["source"], "claude"); + // Step body verbatim under `step`/`change`. + assert_eq!(first["step"]["id"], "s1"); + assert_eq!(first["step"]["actor"], "human:alex"); + assert!(first["change"]["src/main.rs"]["raw"].is_string()); + } + + #[test] + fn dead_end_flag_tracks_ancestry_of_head() { + let path = forked_path(); + let mut out = Vec::new(); + wrap_path(&doc_src("g"), &path, &mut out); + + let dead: std::collections::HashMap<&str, bool> = out + .iter() + .map(|e| { + ( + e["step"]["id"].as_str().unwrap(), + e["dead_end"].as_bool().unwrap(), + ) + }) + .collect(); + assert!(!dead["s1"]); + assert!(!dead["s2"]); + assert!(!dead["s3"]); + assert!(dead["s2a"], "s2a is off the head's ancestry"); + } + + #[test] + fn wrap_graph_filters_by_kind() { + let graph = Graph::from_path(forked_path()); + let mut out = Vec::new(); + let sel = kinds::parse_kind_selector("agent-coding-session/v1.1.0"); + wrap_graph(&doc_src("g"), &graph, Some(&sel), None, &mut out); + assert_eq!(out.len(), 4, "matching kind keeps all steps"); + + out.clear(); + let miss = kinds::parse_kind_selector("agent-coding-session/v2"); + wrap_graph(&doc_src("g"), &graph, Some(&miss), None, &mut out); + assert!(out.is_empty(), "non-matching kind drops the whole path"); + } + + #[test] + fn project_matches_file_base_only() { + let path = forked_path(); // base uri = file:///work/repo + assert!(path_matches_project( + &path, + &canonicalize_or_self(FsPath::new("/work/repo")) + )); + assert!(!path_matches_project( + &path, + &canonicalize_or_self(FsPath::new("/other")) + )); + + let mut vcs = forked_path(); + vcs.path.base = Some(Base::vcs("github:org/repo", "abc")); + assert!(!path_matches_project( + &vcs, + &canonicalize_or_self(FsPath::new("/work/repo")) + )); + } + + // The cache-scanning branch of `select_files` (whole-cache, `--source`, + // `--id`) reads the global config dir; it's covered end-to-end and + // hermetically by `tests/query.rs` (per-process `$TOOLPATH_CONFIG_DIR`), + // so we don't mutate process-global env in a unit test here. + + #[test] + fn select_files_input_only_skips_cache() { + let scope = Scope { + source: None, + ids: vec![], + inputs: vec!["/tmp/some.json".to_string(), "-".to_string()], + project: None, + kind: None, + }; + let files = select_files(&scope).unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].cache_id, "some"); + assert!(matches!(files[1].location, SourceLoc::Stdin)); + assert_eq!(files[1].cache_id, "stdin"); + } +} diff --git a/crates/path-cli/src/schema.rs b/crates/path-cli/src/schema.rs index 381ef7dd..d98d33e4 100644 --- a/crates/path-cli/src/schema.rs +++ b/crates/path-cli/src/schema.rs @@ -21,20 +21,6 @@ use jsonschema::Validator; const SCHEMA_SOURCE: &str = toolpath::SCHEMA_JSON; -/// `meta.kind` URI → bundled kind-schema source. Bundled (rather than -/// fetched from `toolpath.net` at validation time) so validation is -/// offline and deterministic. -const KIND_SCHEMAS: &[(&str, &str)] = &[ - ( - "https://toolpath.net/kinds/agent-coding-session/v1.0.0", - include_str!("../kinds/agent-coding-session/v1.0.0/schema.json"), - ), - ( - "https://toolpath.net/kinds/agent-coding-session/v1.1.0", - include_str!("../kinds/agent-coding-session/v1.1.0/schema.json"), - ), -]; - fn validator() -> &'static Validator { static VALIDATOR: OnceLock = OnceLock::new(); VALIDATOR.get_or_init(|| { @@ -46,18 +32,25 @@ fn validator() -> &'static Validator { } /// Compiled validator for each known kind URI, built once on first use. +/// Sourced from [`crate::kinds::BUNDLED_KINDS`] so the validator set and the +/// `path kind` / `path query --kind` surface stay in lockstep. fn kind_validators() -> &'static HashMap<&'static str, Validator> { static VALIDATORS: OnceLock> = OnceLock::new(); VALIDATORS.get_or_init(|| { - KIND_SCHEMAS + crate::kinds::BUNDLED_KINDS .iter() - .map(|(uri, source)| { - let schema: serde_json::Value = serde_json::from_str(source) - .unwrap_or_else(|e| panic!("bundled kind schema {uri} is not valid JSON: {e}")); + .map(|k| { + let schema: serde_json::Value = + serde_json::from_str(k.schema).unwrap_or_else(|e| { + panic!("bundled kind schema {} is not valid JSON: {e}", k.uri) + }); let v = jsonschema::validator_for(&schema).unwrap_or_else(|e| { - panic!("bundled kind schema {uri} is not a valid JSON Schema: {e}") + panic!( + "bundled kind schema {} is not a valid JSON Schema: {e}", + k.uri + ) }); - (*uri, v) + (k.uri, v) }) .collect() }) diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 27c53d8a..c9a1805a 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -275,11 +275,12 @@ fn render_dot_from_stdin() { #[test] fn query_dead_ends() { + // `dead-ends` is now a jaq form over `path query`'s `dead_end` flag. cmd() .arg("query") - .arg("dead-ends") .arg("--input") .arg(examples_dir().join("path-01-pr.path.json")) + .arg("map(select(.dead_end))") .assert() .success() .stdout(predicate::str::contains("step-002a")); @@ -287,9 +288,9 @@ fn query_dead_ends() { #[test] fn query_ancestors() { + // `ancestors` moved to the `path p query` plumbing namespace. cmd() - .arg("query") - .arg("ancestors") + .args(["p", "query", "ancestors"]) .arg("--input") .arg(examples_dir().join("path-01-pr.path.json")) .arg("--step-id") @@ -363,9 +364,9 @@ fn render_md_accepts_path_jsonl() { fn query_dead_ends_accepts_path_jsonl() { cmd() .arg("query") - .arg("dead-ends") .arg("--input") .arg(examples_dir().join("path-04-exploration.path.jsonl")) + .arg("map(select(.dead_end))") .assert() .success(); } diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs new file mode 100644 index 00000000..0180dc79 --- /dev/null +++ b/crates/path-cli/tests/query.rs @@ -0,0 +1,337 @@ +//! Integration tests for `path query` and `path kind`. +//! +//! Each invocation runs against a throwaway `$TOOLPATH_CONFIG_DIR` sandbox so +//! the cache is hermetic. Fixtures: one `agent-coding-session` doc (a kind the +//! binary bundles a spec for) and one generic git-PR doc (a kind it does not), +//! both of which must remain queryable. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn cmd() -> Command { + Command::cargo_bin("path").unwrap() +} + +/// Write `json` into `/documents/.json`, creating the dir. +fn seed(cfg: &Path, id: &str, json: &str) { + let docs = cfg.join("documents"); + std::fs::create_dir_all(&docs).unwrap(); + std::fs::write(docs.join(format!("{id}.json")), json).unwrap(); +} + +/// An agent-coding-session graph: s1(user, "RefCell") → s2(assistant, 60k +/// input tokens, failed Bash, touches cmd_resume.rs) → s3(head); s2a is a +/// dead-end branch off s1. +const CLAUDE_DOC: &str = r#"{ + "graph": {"id": "g1"}, + "paths": [{ + "path": {"id": "sess-1", "base": {"uri": "file:///work/repo"}, "head": "s3"}, + "meta": {"kind": "https://toolpath.net/kinds/agent-coding-session/v1.1.0", "source": "claude", "title": "Add path query"}, + "steps": [ + {"step": {"id": "s1", "actor": "human:alex", "timestamp": "2026-06-20T10:00:00Z"}, + "change": {"agent://claude/s1": {"structural": {"type": "conversation.append", "role": "user", "text": "use a RefCell here"}}}}, + {"step": {"id": "s2", "parents": ["s1"], "actor": "agent:claude-code", "timestamp": "2026-06-20T10:01:00Z"}, + "change": {"src/cmd_resume.rs": {"structural": {"type": "conversation.append", "role": "assistant", "text": "done", "token_usage": {"input_tokens": 60000, "output_tokens": 412}, "tool_uses": [{"name": "Bash", "result": {"is_error": true}}]}}}}, + {"step": {"id": "s2a", "parents": ["s1"], "actor": "agent:claude-code", "timestamp": "2026-06-20T10:02:00Z"}, + "change": {"src/dead.rs": {"raw": "@@ dead @@"}}}, + {"step": {"id": "s3", "parents": ["s2"], "actor": "human:alex", "timestamp": "2026-06-20T10:03:00Z"}, + "change": {"src/cmd_resume.rs": {"raw": "@@ final @@"}}} + ] + }] +}"#; + +/// A generic git-PR graph with no `meta.kind` — a kind the binary bundles no +/// spec for. Must still load and be queryable. +const GIT_DOC: &str = r#"{ + "graph": {"id": "g2"}, + "paths": [{ + "path": {"id": "pr-42", "base": {"uri": "github:org/repo", "ref": "abc"}, "head": "c2"}, + "steps": [ + {"step": {"id": "c1", "actor": "human:bob", "timestamp": "2026-06-21T09:00:00Z"}, + "change": {"README.md": {"raw": "@@ -1 +1 @@\n-old\n+new"}}}, + {"step": {"id": "c2", "parents": ["c1"], "actor": "human:bob", "timestamp": "2026-06-21T09:05:00Z"}, + "change": {"src/cmd_resume.rs": {"raw": "@@ pr change @@"}}} + ] + }] +}"#; + +/// A full sandbox with both fixtures seeded. +fn sandbox() -> tempfile::TempDir { + let cfg = tempfile::tempdir().unwrap(); + seed(cfg.path(), "claude-sess1", CLAUDE_DOC); + seed(cfg.path(), "git-pr42", GIT_DOC); + cfg +} + +fn query<'a>(cfg: &Path, args: impl IntoIterator) -> assert_cmd::assert::Assert { + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg) + .arg("query") + .args(args) + .assert() +} + +// ── The four motivating examples ───────────────────────────────────── + +#[test] +fn steps_mentioning_refcell() { + let cfg = sandbox(); + query( + cfg.path(), + [r#"[.[] | select(any(.. | strings; test("RefCell"))) | .step.id]"#], + ) + .success() + .stdout(predicate::str::contains("s1")) + .stdout(predicate::str::contains("s2").not()); +} + +#[test] +fn steps_touching_a_file_across_sessions() { + // cmd_resume.rs is touched in both the claude doc (s2, s3) and the git doc + // (c2): the dedup-by-cache_id rollup returns both sessions. + let cfg = sandbox(); + query( + cfg.path(), + [r#"[.[] | select(any(.change | keys[]; endswith("cmd_resume.rs"))) | .cache_id] | unique"#], + ) + .success() + .stdout(predicate::str::contains("claude-sess1")) + .stdout(predicate::str::contains("git-pr42")); +} + +#[test] +fn turns_over_50k_input_tokens() { + let cfg = sandbox(); + query( + cfg.path(), + [r#"[.[] | select(any(.change[].structural.token_usage; .input_tokens > 50000)) | .step.id]"#], + ) + .success() + .stdout(predicate::str::contains("s2")); +} + +#[test] +fn failed_bash_in_claude_sessions() { + let cfg = sandbox(); + query( + cfg.path(), + [ + "--source", + "claude", + r#"[.[] | select(any(.change[].structural.tool_uses[]?; .name == "Bash" and .result.is_error)) | .step.id]"#, + ], + ) + .success() + .stdout(predicate::str::contains("s2")); +} + +// ── Rollups ────────────────────────────────────────────────────────── + +#[test] +fn top_n_by_tokens() { + let cfg = sandbox(); + query( + cfg.path(), + [r#"map({step: .step.id, tokens: ([.change[].structural.token_usage // empty | (.input_tokens//0)+(.output_tokens//0)] | add // 0)}) | sort_by(-.tokens) | .[0].step"#], + ) + .success() + .stdout(predicate::str::contains("s2")); +} + +#[test] +fn step_count_per_source() { + let cfg = sandbox(); + // The git doc has no source; group_by puts it under null. + query( + cfg.path(), + ["group_by(.path.meta.source) | map({source: .[0].path.meta.source, steps: length}) | length"], + ) + .success() + .stdout(predicate::str::starts_with("2")); +} + +// ── Dead ends (the former `dead-ends` subcommand, now a jaq form) ───── + +#[test] +fn dead_ends_as_jaq_form() { + let cfg = sandbox(); + query(cfg.path(), ["[.[] | select(.dead_end) | .step.id]"]) + .success() + .stdout(predicate::str::contains("s2a")) + .stdout(predicate::str::contains("s1").not()); +} + +// ── File selection ─────────────────────────────────────────────────── + +#[test] +fn source_selects_by_prefix() { + let cfg = sandbox(); + // Only the claude doc's 4 steps. + query(cfg.path(), ["--source", "claude", "length"]) + .success() + .stdout(predicate::str::starts_with("4")); +} + +#[test] +fn id_selects_one_document() { + let cfg = sandbox(); + query(cfg.path(), ["--id", "git-pr42", "length"]) + .success() + .stdout(predicate::str::starts_with("2")); +} + +#[test] +fn whole_cache_loads_both_docs() { + let cfg = sandbox(); + query(cfg.path(), ["length"]) + .success() + .stdout(predicate::str::starts_with("6")); +} + +// ── Content scoping ────────────────────────────────────────────────── + +#[test] +fn kind_prefix_match_v1_keeps_session() { + let cfg = sandbox(); + query(cfg.path(), ["--kind", "agent-coding-session/v1", "length"]) + .success() + .stdout(predicate::str::starts_with("4")); +} + +#[test] +fn kind_v2_matches_nothing() { + let cfg = sandbox(); + query(cfg.path(), ["--kind", "agent-coding-session/v2", "length"]) + .success() + .stdout(predicate::str::starts_with("0")); +} + +// ── Robustness ─────────────────────────────────────────────────────── + +#[test] +fn malformed_doc_is_skipped_with_warning() { + let cfg = sandbox(); + seed(cfg.path(), "claude-broken", "{ not json"); + // The good docs still load (6 steps); the broken one warns on stderr. + query(cfg.path(), ["length"]) + .success() + .stdout(predicate::str::starts_with("6")) + .stderr(predicate::str::contains("warning: skipping")); +} + +#[test] +fn empty_result_exits_zero() { + let cfg = sandbox(); + query(cfg.path(), [r#"map(select(.step.id == "nope"))"#]) + .success() + .stdout(predicate::str::contains("[]")); +} + +#[test] +fn deterministic_step_order_within_a_path() { + let cfg = sandbox(); + // Document order is s1, s2, s2a, s3 — stable across runs. + query(cfg.path(), ["--id", "claude-sess1", "[.[].step.id]", "-c"]) + .success() + .stdout(predicate::str::contains(r#"["s1","s2","s2a","s3"]"#)); +} + +#[test] +fn compact_json_when_piped() { + let cfg = sandbox(); + // assert_cmd's stdout is not a TTY, so output is compact (no pretty + // newlines inside the object). + query(cfg.path(), ["--id", "git-pr42", ".[0] | {id: .step.id}"]) + .success() + .stdout(predicate::str::diff("{\"id\":\"c1\"}\n")); +} + +#[test] +fn invalid_filter_exits_one() { + let cfg = sandbox(); + query(cfg.path(), ["map(select("]) + .failure() + .stderr(predicate::str::contains("jq filter")); +} + +#[test] +fn raw_prints_strings_unquoted() { + let cfg = sandbox(); + // `-r` on a stream of strings: each line is the raw value, no JSON quotes. + query( + cfg.path(), + ["--id", "git-pr42", "[.[].step.id] | sort | .[]", "-r"], + ) + .success() + .stdout(predicate::str::diff("c1\nc2\n")); +} + +#[test] +fn raw_leaves_non_strings_as_json() { + let cfg = sandbox(); + // jq parity: `-r` only affects string outputs; numbers/objects stay JSON. + query(cfg.path(), ["--id", "git-pr42", "length", "-r"]) + .success() + .stdout(predicate::str::diff("2\n")); + query( + cfg.path(), + ["--id", "git-pr42", ".[0] | {id: .step.id}", "-r"], + ) + .success() + .stdout(predicate::str::diff("{\"id\":\"c1\"}\n")); +} + +#[test] +fn raw_unescapes_string_content() { + let cfg = sandbox(); + // A string containing a newline prints with a real newline under -r, + // not the two-character escape `\n`. (The filter yields a literal, so the + // scoped docs are irrelevant here.) + query(cfg.path(), [r#"["one\ntwo"] | .[]"#, "-r"]) + .success() + .stdout(predicate::str::diff("one\ntwo\n")); +} + +// ── path kind ──────────────────────────────────────────────────────── + +#[test] +fn kind_lists_bundled_kinds() { + cmd() + .arg("kind") + .assert() + .success() + .stdout(predicate::str::contains("agent-coding-session")) + .stdout(predicate::str::contains("v1.1.0")); +} + +#[test] +fn kind_prints_newest_schema() { + cmd() + .args(["kind", "agent-coding-session"]) + .assert() + .success() + .stdout(predicate::str::contains( + "kinds/agent-coding-session/v1.1.0/schema.json", + )); +} + +#[test] +fn kind_pins_specific_version() { + cmd() + .args(["kind", "agent-coding-session/v1.0.0"]) + .assert() + .success() + .stdout(predicate::str::contains( + "kinds/agent-coding-session/v1.0.0/schema.json", + )); +} + +#[test] +fn kind_unknown_errors() { + cmd() + .args(["kind", "no-such-kind"]) + .assert() + .failure() + .stderr(predicate::str::contains("Bundled kinds")); +} diff --git a/docs/superpowers/specs/2026-06-22-path-query-command-design.md b/docs/superpowers/specs/2026-06-22-path-query-command-design.md new file mode 100644 index 00000000..5df8134f --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-path-query-command-design.md @@ -0,0 +1,271 @@ +# `path query` — querying the local cache + +**Status:** Design proposal +**Date:** 2026-06-22 + +## Goal + +`path query` reads the local cache at `~/.toolpath/documents/` and answers +questions across every cached document: + +- "find every turn that mentions `RefCell`" +- "which sessions touched `cmd_resume.rs`?" +- "which turns burned > 50k tokens?" +- "the 10 steps that cost the most tokens" +- "every failed `Bash` call in my Claude sessions" + +Toolpath is general (see `RFC.md`): a path can be an agent session, a PR, +a release. A path's step shape comes from its `meta.kind`, and the query +model works on whatever shape a kind defines. + +## The model + +One command, one idea: **load every cached step into a single JSON array +and transform it with a jaq filter.** + +``` +path query [scope flags] [''] +``` + +The filter is jaq — the language LLMs and power users already know — and +it does the matching, projection, sorting, grouping, and top-N. With no +filter, `path query` emits the scoped array; with a filter, it emits what +the filter produces. + +The filter receives the whole array, so a per-element match is +`map(select(…))` or `.[] | select(…)`, and ranking and aggregation are +`sort_by(-.tokens) | .[:10]`, `group_by(.path.meta.source)`, +`unique_by(...)`. + +`path query 'f'` equals `path query | jq 'f'`: bare `path query` prints +the array, and a filter is the same as piping that array to `jq`. The +filter runs in-process via the `jaq` crate (pure-Rust jq, regex enabled +for `test`/`match`). + +## The step object + +Each array element is a Toolpath step — `step`, `change`, `meta` verbatim +— wrapped with its source context: + +```json +{ + "cache_id": "claude-abc123", + "path": { + "id": "session-…", + "base": { "uri": "file:///Users/ben/empathic/oss/toolpath" }, + "meta": { "kind": "https://toolpath.net/kinds/agent-coding-session/v1.1.0", "source": "claude", "title": "Add path query" } + }, + "step": { "id": "step-0042", "parents": ["step-0041"], "actor": "agent:claude-code", "timestamp": "2026-06-20T14:03:11Z" }, + "change": { + "claude://session-…": { + "structural": { + "type": "conversation.append", "role": "assistant", "text": "…", + "token_usage": { "input_tokens": 8123, "output_tokens": 412, … }, + "tool_uses": [ { "name": "Bash", "result": { "is_error": false } } ] + } + } + }, + "meta": { "intent": "…" }, + "dead_end": false +} +``` + +The wrapper adds three keys: `cache_id`, `path` (the parent path's `id`, +`base`, and `meta`), and `dead_end` (whether the step sits off the head's +ancestry, computed while loading); `path query --help` lists them. +Everything under `step`/`change`/`meta` is verbatim Toolpath. + +`change` maps each artifact to its perspectives; the structure inside is +what the **kind** defines — here, an `agent-coding-session` +`conversation.append`. Its fields (`token_usage`, `tool_uses`, `role`, +`text`, …) sit directly under `structural` alongside `type`, reached as +`.change[].structural.token_usage`. A git PR step's `change` holds raw +diffs and structural ops. The field set varies by kind, which is what +`path kind` (below) surfaces. + +**Identity.** Step IDs repeat across sessions, so an element's unique +identity is the triple `(cache_id, path.id, step.id)`. Group or dedup on +that triple, or on `cache_id` for session-level rollups. + +## Cold start: `path kind` + +To learn a step's shape before querying, run `path kind`. A step's shape +is set by its kind, so that is what the command names. + +``` +path kind +``` + +It lists the kinds the binary bundles a spec for (today +`agent-coding-session`). `path kind ` prints that kind's bundled +`schema.json`. The schema names every field, its type, and — in its +`description` fields — the semantics behind it (e.g. that `token_usage` is +a group total). `` is a value-enum of the bundled kinds, so +`path help kind` lists them. + +``` +path kind agent-coding-session +path kind agent-coding-session/v1.0.0 +path query --kind agent-coding-session 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' +``` + +The bundled schemas live at +`crates/path-cli/kinds///schema.json` and publish at +`https://toolpath.net/kinds///`. For a doc whose kind the +binary bundles a spec for, `path kind` shows it; for any other doc, read a +sample with `path query --kind … '.[0]'`. `path query --help` points here. + +### Which version `path kind` shows + +The binary bundles each version of a kind's spec it ships with (today +`agent-coding-session` v1.0.0 and v1.1.0). `path kind ` shows the +newest; a trailing `/` (`path kind /v1.0.0`) pins one, +matching the same prefix rule as `--kind`. + +## Scope flags + +The filter expresses *what* to match; scope flags choose *which* documents +load. Two kinds: + +**File selection** — which cached files to read: + +| Flag | Effect | +| --- | --- | +| `--source ` | claude/gemini/codex/opencode/cursor/pi/git/github — selects files by cache-id prefix before parsing (fast) | +| `--id ` | one (repeatable) cached document | +| `--input ` | an off-cache file (`-` for stdin) | + +**Content scoping** — match on a parsed field: + +| Flag | Effect | +| --- | --- | +| `--project ` | canonicalizes the path and compares it against `base`/cwd | +| `--kind ` | semver-prefix match (see below) | + +Everything else is a jaq predicate on the real structure: actor +(`.step.actor | startswith("agent:")`), files touched +(`.change | keys[]`), time (`.step.timestamp >= "2026-06-15"`), dead ends +(`select(.dead_end)`), structural type (`.change[].structural.type`), plus +ranking and aggregation. + +### `--kind` matching + +`path.meta.kind` is a semver-versioned URI +(`…/kinds//v..`). `--kind` matches a *prefix* +of `(name, major, minor, patch)`: + +| `--kind` | Matches | +| --- | --- | +| `agent-coding-session` | any version | +| `agent-coding-session/v1` | `v1.*.*` | +| `agent-coding-session/v1.0` | `v1.0.*` | +| `agent-coding-session/v1.0.0` | exactly that | + +A bare name matches any version; a full URI matches exactly; the `v` is +optional. Matching compares parsed `(name, major, minor, patch)` tuples, +so `v1` matches `v1.9.0` and keeps `v10.0.0` separate. + +## Examples + +The motivating questions, as commands (the `change` paths come from +`path kind`). The filter runs over the whole array, so selection reads +`map(select(…))`: + +| Question | Command | +| --- | --- | +| Steps mentioning `RefCell` | `path query 'map(select(any(.. \| strings; test("RefCell"))))'` | +| Steps that touched `cmd_resume.rs` | `path query 'map(select(any(.change \| keys[]; endswith("cmd_resume.rs"))))'` | +| Turns over 50k input tokens | `path query 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))'` | +| Failed `Bash` calls in Claude sessions | `path query --source claude 'map(select(any(.change[].structural.tool_uses[]?; .name == "Bash" and .result.is_error)))'` | + +The first walks the step with recursive descent (`.. | strings`) to match +a term anywhere in it — the structure-agnostic search. +`any(generator; condition)` tests each generated value and keeps the step +when one matches. + +The whole array is in scope, so rollups run in the same filter: + +```bash +# which sessions touched cmd_resume.rs, deduped +path query '[.[] | select(any(.change | keys[]; endswith("cmd_resume.rs"))) | .cache_id] | unique' + +# step count per source +path query 'group_by(.path.meta.source) | map({source: .[0].path.meta.source, steps: length})' + +# top 10 steps by total tokens (input + output + cache) +path query --kind agent-coding-session ' + map({cache_id, step: .step.id, + tokens: ([.change[].structural.token_usage // empty + | (.input_tokens//0)+(.output_tokens//0)+(.cache_read_tokens//0)+(.cache_write_tokens//0)] | add // 0)}) + | sort_by(-.tokens) | .[:10]' +``` + +## How it runs + +Enumerate via `cmd_cache::list_cached()` (newest-first), then select files +by `--source`/`--id` prefix (a filename match, before parsing). Parse each +survivor with `Graph::from_json` and walk its `paths`. For each inline path +that passes `--project`/`--kind` (matched on the path's `base`/`meta`), +compute the dead-end set once via +`toolpath::v1::query::dead_ends(steps, &path.head)`, and wrap each step in +its envelope (`cache_id` + `path` context + `dead_end`). Assemble the array +in a deterministic order (graph order × path order × step order), run the +jaq filter once over it, and print. + +`path kind` prints the requested kind/version's bundled `schema.json`, +or — with no argument — lists the bundled kinds. + +A file that fails to parse is skipped with a stderr warning. The code +lives in a small `crates/path-cli/src/query/` module, with `cmd_query.rs` +and `cmd_kind.rs` as thin clap layers over it. + +Output mirrors jq: pretty-printed JSON on a TTY, compact when piped (`-c` +to force). A top-level array prints as a JSON array; `… | .[]` yields +JSONL. Slice with `.[:N]` in the filter. + +**Memory.** The whole scoped result set stays in memory while the filter +runs; ranking and aggregation read across all of it. The index (below) +carries this to larger caches. + +## Future, not in v1 + +- **Index.** For a large cache, a derived, rebuildable index (e.g. + SQLite + FTS5) accelerates the same command, flags, and output. +- **Redaction.** A transform stage between projection and output could + scrub secrets/PII. +- **Remote.** `--remote ` could run the same filters against + Pathbase once its API exposes filtering. + +## Testing + +Unit-test the wrapping (a fixture step emerges verbatim under +`step`/`change`/`meta`, with `cache_id`/`path` context attached), +`dead_end` over a small DAG, the walk from a graph's `paths` to their +steps, `path kind` printing a bundled `schema.json`, +`--kind` matching at each specificity (`v1` vs `v10` included), and file +selection by `--source`/`--id`. Integration via `assert_cmd` with a +`$TOOLPATH_CONFIG_DIR` sandbox of fixture docs: the four examples plus a +sort/top-N rollup, `path kind` output, a doc whose kind the binary bundles +a spec for and one it does not (both queryable), a malformed doc skipped +with a warning, deterministic array order across a parallel parse, and +compact JSON when piped. + +The work is additive to `path-cli`: new `path query` and `path kind` +porcelain commands plus the `jaq` dependency. **Breaking:** `path query`'s +former subcommands change — `ancestors` moves to `path p query ancestors`, +and `dead-ends`/`filter` become jaq forms (`map(select(.dead_end))`, +`map(select(.step.actor | startswith("agent:")))`). Pre-1.0, so a minor +version bump; the `CHANGELOG.md` entry calls out the change, and the +`CLAUDE.md` CLI docs update. + +## Decisions + +1. **Embed `jaq`.** `path query` runs jaq in-process (pure-Rust) for the + one-liner UX; `path query | jq` is the same filter piped out. +2. **Whole-array input.** The filter receives every scoped step as one + array, so ranking and aggregation are plain jaq. +3. **`path kind` prints the bundled `schema.json`.** The schema is the + field/type/semantics reference, authored once and shipped with the + binary. +4. **Empty result exits 0.** `path query` is a stream transformer; exit 0 + means it ran, and exit 1 means an error. diff --git a/site/_data/crates.json b/site/_data/crates.json index abf80299..c9335061 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -105,7 +105,7 @@ }, { "name": "path-cli", - "version": "0.14.0", + "version": "0.15.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", diff --git a/site/index.md b/site/index.md index cc6c308f..1133b72f 100644 --- a/site/index.md +++ b/site/index.md @@ -233,11 +233,9 @@ path p import git --repo . --branch main --no-cache | path p render dot | dot -T # Import from Claude conversation logs path p import claude --project /path/to/project --no-cache --pretty -# Query for dead ends -path query dead-ends --input doc.json - -# Filter by actor -path query filter --input doc.json --actor "agent:" +# Query the local cache with a jaq (jq) filter — dead ends, or steps by an agent +path query 'map(select(.dead_end))' +path query --input doc.json 'map(select(.step.actor | startswith("agent:")))' ```