diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f31478..5f143f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,20 @@ hand. and `--no-sync` opts out. This is the piece that makes the cache an implementation detail: a new user can run `path query` with no setup and get their sessions. + - `--project-under ` on `p cache sync` and `path query` + restricts ingestion (and the query's reads) to sessions whose + project directory (recorded cwd) is under a directory. Stat gate first, always: unchanged+cached artifacts skip + before any scope check; only artifacts that would cost a derive get + the constraint, with a one-line peek for codex and copilot (whose + cwd lives inside the session file) memoized into the manifest so it + happens at most once per artifact — and a derive never clobbers the + memoized peeked cwd. The copilot peek tolerates `session.start` + anywhere in the first lines and top-level cwd keys. Claude project + matching happens in slug space (its dir slugs are lossy), and pi + project scoping compares in its dir-encoded space so hyphenated + paths match. Out-of-scope artifacts are noted in the manifest + ("known, not materialized") but not derived, tallied separately + (`N out of scope`), and never touch a materialized record's stamp. - **`toolpath-codex`** (0.6.1, extends the bump below): `session_id_from_stem` is public — the trailing UUID of a rollout filename stem (or the whole stem when it has none), the same diff --git a/CLAUDE.md b/CLAUDE.md index 2bc061c7..7fb2d744 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,7 @@ cargo run -p path-cli -- p cache ls cargo run -p path-cli -- p cache rm cargo run -p path-cli -- p cache sync # ingest new/changed sessions from every harness cargo run -p path-cli -- p cache sync claude codex # only these artifact types +cargo run -p path-cli -- p cache sync --project-under ~/work/proj # only sessions whose project is under this directory # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json @@ -143,6 +144,8 @@ cargo run -p path-cli -- p render md --input doc.json --detail full # Query the whole local cache with a jaq (jq) filter over wrapped steps. # Queries auto-sync their scope first (--source claude syncs only claude; # --input-only queries never touch the cache); --no-sync opts out. +# --project-under scopes both the sync and the read to sessions whose +# project directory is under that subtree. 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:")))' @@ -215,7 +218,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`: 353 unit + 119 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, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). 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`: 358 unit + 121 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, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). 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` @@ -285,6 +288,6 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when `p cache rm` evicts a doc (rm downgrades the record; the next sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive / peek-dir / scope-match — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--project-under` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--project-under ` on both `p cache sync` and `path query` restricts ingestion to sessions whose project directory (recorded cwd) is under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share` and `/path:query`). The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` context commands and model-issued Bash must not contain `$PWD`/variables (Claude Code's permission checker rejects commands it can't statically analyze — hence the `sessions` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. - `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index ac825fee..a386019c 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -6,6 +6,8 @@ use anyhow::Result; use clap::Subcommand; +#[cfg(not(target_os = "emscripten"))] +use std::path::PathBuf; use crate::cache::{list_cached, remove_cached}; #[cfg(not(target_os = "emscripten"))] @@ -31,6 +33,13 @@ pub enum CacheOp { /// Artifact types to sync (default: every agent harness) #[arg(value_enum)] types: Vec, + + /// Only ingest sessions whose project directory (recorded + /// working directory) lies under this directory (subtree + /// match). Out-of-scope artifacts are noted in the manifest but + /// not derived. + #[arg(long)] + project_under: Option, }, } @@ -39,7 +48,7 @@ pub fn run(op: CacheOp) -> Result<()> { CacheOp::Ls => run_ls(), CacheOp::Rm { id } => run_rm(&id), #[cfg(not(target_os = "emscripten"))] - CacheOp::Sync { types } => run_sync(types), + CacheOp::Sync { types, project_under } => run_sync(types, project_under), } } @@ -68,11 +77,11 @@ fn run_rm(id: &str) -> Result<()> { } #[cfg(not(target_os = "emscripten"))] -fn run_sync(types: Vec) -> Result<()> { +fn run_sync(types: Vec, project_under: Option) -> Result<()> { let explicit = !types.is_empty(); let types = resolve_types(&types); let bundle = HarnessBundle::from_environment(); - let outcomes = sync_bundle(&bundle, &types, &mut Progress::new())?; + let outcomes = sync_bundle(&bundle, &types, project_under.as_deref(), &mut Progress::new())?; eprint!("{}", render_summary(&outcomes, explicit)); Ok(()) } @@ -190,6 +199,9 @@ fn render_summary(outcomes: &[(ArtifactType, SyncOutcome)], explicit: bool) -> S if o.failed > 0 { s.push_str(&format!(", {} failed", o.failed)); } + if o.out_of_scope > 0 { + s.push_str(&format!(", {} out of scope", o.out_of_scope)); + } s.push('\n'); } if s.is_empty() { @@ -238,6 +250,7 @@ mod tests { updated: 1, unchanged: 3, failed: 0, + out_of_scope: 0, }, ), (ArtifactType::Cursor, SyncOutcome::default()), @@ -259,6 +272,7 @@ mod tests { updated: 0, unchanged: 1, failed: 2, + out_of_scope: 0, }, )]; let s = render_summary(&outcomes, false); diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index b7b7df7b..5fc26c80 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -42,6 +42,13 @@ pub struct QueryArgs { #[arg(long)] project: Option, + /// Keep only sessions whose project directory (the path base) + /// lies under this directory (subtree match; the session's + /// recorded working directory, not the files it touched), and + /// scope the implicit sync to it likewise. + #[arg(long)] + project_under: Option, + /// Keep only paths whose meta.kind matches this selector /// (semver prefix, e.g. `agent-coding-session` or `…/v1.0`). #[arg(long)] @@ -103,6 +110,7 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { ids: args.ids, inputs: args.input, project: args.project, + project_under: args.project_under, kind: args.kind, }; @@ -123,7 +131,7 @@ fn sync_query_scope(args: &QueryArgs) { return; } let bundle = crate::harness::HarnessBundle::from_environment(); - match crate::sync::sync_bundle(&bundle, &types, &mut ()) { + match crate::sync::sync_bundle(&bundle, &types, args.project_under.as_deref(), &mut ()) { Ok(outcomes) => { for (t, o) in outcomes { if o.new + o.updated + o.failed > 0 { diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs index 08e079db..458cdebd 100644 --- a/crates/path-cli/src/query/mod.rs +++ b/crates/path-cli/src/query/mod.rs @@ -30,6 +30,9 @@ pub struct Scope { pub inputs: Vec, /// `--project`: keep only paths whose `base` resolves to this directory. pub project: Option, + /// `--project-under`: keep only paths whose `base` lives under this + /// directory (subtree match). + pub project_under: Option, /// `--kind`: keep only paths whose `meta.kind` matches this selector. pub kind: Option, } @@ -94,6 +97,7 @@ impl DocSource { fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Result<()> { let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector); let project = scope.project.as_deref().map(canonicalize_or_self); + let project_under = scope.project_under.as_deref().map(canonicalize_or_self); for src in select_files(scope)? { let graph = match read_source(&src) { @@ -115,6 +119,7 @@ fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Resul &graph, kind_sel.as_ref(), project.as_deref(), + project_under.as_deref(), &mut steps, ); drop(graph); @@ -231,6 +236,7 @@ fn wrap_graph( graph: &Graph, kind_sel: Option<&KindSelector>, project: Option<&FsPath>, + project_under: Option<&FsPath>, out: &mut Vec, ) { for entry in &graph.paths { @@ -249,6 +255,11 @@ fn wrap_graph( { continue; } + if let Some(dir) = project_under + && !path_matches_project_under(path, dir) + { + continue; + } wrap_path(src, path, out); } @@ -307,13 +318,17 @@ fn path_context(path: &Path) -> serde_json::Value { /// 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 + base_fs_path(path).is_some_and(|p| p == project) +} + +fn path_matches_project_under(path: &Path, project_under: &FsPath) -> bool { + base_fs_path(path).is_some_and(|p| p.starts_with(project_under)) +} + +fn base_fs_path(path: &Path) -> Option { + let base = path.path.base.as_ref()?; + let fs = base.uri.strip_prefix("file://")?; + Some(canonicalize_or_self(FsPath::new(fs))) } fn canonicalize_or_self(p: &FsPath) -> PathBuf { @@ -406,12 +421,12 @@ mod tests { 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); + wrap_graph(&doc_src("g"), &graph, Some(&sel), None, 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); + wrap_graph(&doc_src("g"), &graph, Some(&miss), None, None, &mut out); assert!(out.is_empty(), "non-matching kind drops the whole path"); } @@ -447,6 +462,7 @@ mod tests { ids: vec![], inputs: vec!["/tmp/some.json".to_string(), "-".to_string()], project: None, + project_under: None, kind: None, }; let files = select_files(&scope).unwrap(); diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index e5b0242e..c8dae375 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result, anyhow}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use super::sources::{self, ArtifactSource}; use crate::artifact::{ArtifactRef, ArtifactType}; @@ -23,7 +23,8 @@ const MANIFEST_CHECKPOINT_EVERY_WRITES: usize = 10; /// What the manifest remembers about one known artifact. A record /// with a `cache_id` is materialized in the cache; one without is -/// merely known — evicted by `p cache rm`. +/// merely known — seen during an out-of-scope sync, or evicted by +/// `p cache rm`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct SyncRecord { /// Filesystem path the artifact is keyed under: the project @@ -57,11 +58,13 @@ pub(crate) struct SyncOutcome { pub(crate) updated: usize, pub(crate) unchanged: usize, pub(crate) failed: usize, + /// Artifacts needing work that a `--project-under` constraint excluded. + pub(crate) out_of_scope: usize, } impl SyncOutcome { pub(crate) fn total(&self) -> usize { - self.new + self.updated + self.unchanged + self.failed + self.new + self.updated + self.unchanged + self.failed + self.out_of_scope } } @@ -70,7 +73,8 @@ impl SyncOutcome { /// sync logically with no UI pass `&mut ()`. Per artifact type: /// one `begin` once the stat pass has sized the pending work, one /// `failed` per derive error (before its `tick`), one `tick` per -/// finished derive, one `end` when the type's loop is done. +/// finished derive or scope skip, one `end` when the type's loop +/// is done. pub(crate) trait SyncObserver { fn begin(&mut self, _artifact_type: ArtifactType, _pending: usize) {} fn tick(&mut self) {} @@ -92,6 +96,7 @@ impl SyncObserver for () {} pub(crate) fn sync_bundle( bundle: &HarnessBundle, types: &[ArtifactType], + project_under: Option<&Path>, observer: &mut dyn SyncObserver, ) -> Result> { let manifest = load_manifest()?; @@ -104,7 +109,7 @@ pub(crate) fn sync_bundle( out.push((artifact_type, SyncOutcome::default())); continue; }; - let artifacts = source.enumerate(); + let artifacts = source.enumerate(project_under); let records = manifest .get(artifact_type.name()) .cloned() @@ -114,6 +119,7 @@ pub(crate) fn sync_bundle( artifact_type, &artifacts, &records, + project_under, observer, )?; out.push((artifact_type, outcome)); @@ -173,6 +179,7 @@ fn sync_artifacts( artifact_type: ArtifactType, artifacts: &[ArtifactRef], records: &BTreeMap, + project_under: Option<&Path>, observer: &mut dyn SyncObserver, ) -> Result { let mut outcome = SyncOutcome::default(); @@ -200,8 +207,48 @@ fn sync_artifacts( .or_default() .insert(artifact.id.clone(), record); }; - // An artifact without a path must not erase one an earlier - // run recorded. + // Scope gate: only artifacts that would cost a derive get the + // constraint check (with a bounded peek for codex/copilot, + // memoized in the record so it happens at most once per + // artifact). The source compares in its own key space — + // claude and pi keys are lossy dir encodings. + if let Some(project_under) = project_under { + let dir = artifact + .path + .clone() + .or_else(|| existing.and_then(|r| r.path.clone())) + .or_else(|| source.peek_dir(&artifact.id)); + let in_scope = dir + .as_deref() + .is_some_and(|d| source.in_scope(d, project_under)); + if !in_scope { + outcome.out_of_scope += 1; + // Remember what we learned — but never touch the stamp + // of a materialized record, or its staleness would be + // masked from the next in-scope sync. + if existing.is_none_or(|r| r.cache_id.is_none()) { + stage( + &mut writes, + SyncRecord { + path: dir, + cache_id: None, + modified: artifact.modified, + size: artifact.size, + synced_at: Utc::now(), + }, + ); + unflushed += 1; + } + observer.tick(); + if unflushed >= MANIFEST_CHECKPOINT_EVERY_WRITES { + flush_writes(&mut writes)?; + unflushed = 0; + } + continue; + } + } + // An artifact without a path must not erase one a previous + // pass peeked and memoized (codex/copilot cwd). let memoized_path = artifact .path .clone() @@ -522,7 +569,7 @@ mod tests { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap(); - let artifacts = source.enumerate(); + let artifacts = source.enumerate(None); assert_eq!(artifacts.len(), 1); assert_eq!(artifacts[0].id, "sess-aaa"); assert_eq!(artifacts[0].path.as_deref(), Some("/test/project")); @@ -541,7 +588,7 @@ mod tests { write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); let bundle = claude_bundle(home); - let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); assert_eq!(outcomes.len(), 1); let (_, first) = outcomes[0]; assert_eq!( @@ -565,7 +612,8 @@ mod tests { "cache doc must exist for {cache_id}" ); - let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, second) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!( (second.new, second.updated, second.unchanged, second.failed), (0, 0, 2, 0) @@ -578,7 +626,7 @@ mod tests { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id @@ -596,7 +644,8 @@ mod tests { body.push('\n'); std::fs::write(&file, body).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, outcome) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!( ( outcome.new, @@ -619,7 +668,7 @@ mod tests { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex], &mut ()).unwrap(); + let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex], None, &mut ()).unwrap(); assert_eq!(outcomes[0].1, SyncOutcome::default()); assert!( load_manifest().unwrap().is_empty(), @@ -633,13 +682,14 @@ mod tests { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); // Losing the manifest (or a prior manual `p import`) leaves a // cache entry sync doesn't know about; re-syncing must // overwrite it, not die on the exists-check. std::fs::remove_file(manifest_path().unwrap()).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, outcome) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!((outcome.new, outcome.failed), (1, 0)); }); } @@ -650,7 +700,7 @@ mod tests { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap(); - let mut artifacts = source.enumerate(); + let mut artifacts = source.enumerate(None); artifacts.push(make_ref(ArtifactType::Claude, "does-not-exist")); let outcome = sync_artifacts( @@ -658,6 +708,7 @@ mod tests { ArtifactType::Claude, &artifacts, &BTreeMap::new(), + None, &mut (), ) .unwrap(); @@ -676,7 +727,7 @@ mod tests { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id .clone() @@ -697,7 +748,8 @@ mod tests { ) .unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, outcome) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!( (outcome.new, outcome.updated, outcome.unchanged), (0, 1, 0), @@ -714,7 +766,8 @@ mod tests { ); // And the grown chain settles: a third sync is a no-op. - let (_, again) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, again) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!((again.updated, again.unchanged), (0, 1)); }); } @@ -724,7 +777,7 @@ mod tests { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); // A record whose stamps are all None (stat failed when it // was written) must not match a stub whose stat also @@ -740,6 +793,7 @@ mod tests { ArtifactType::Claude, &[artifact], &records, + None, &mut (), ) .unwrap(); @@ -768,7 +822,8 @@ mod tests { record_artifact(artifact, &derived.cache_id).unwrap(); // The import's stamp must match sync's own enumeration. - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, outcome) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!( ( outcome.new, @@ -781,12 +836,155 @@ mod tests { }); } + #[test] + fn project_under_scopes_path_keyed_enumeration() { + with_cfg(|home| { + write_claude_session(home, "-scope-alpha", "aaaa1111-x", "In alpha"); + write_claude_session(home, "-scope-beta", "bbbb2222-x", "In beta"); + let bundle = claude_bundle(home); + + let (_, scoped) = sync_bundle( + &bundle, + &[ArtifactType::Claude], + Some(Path::new("/scope/alpha")), + &mut (), + ) + .unwrap()[0]; + assert_eq!((scoped.new, scoped.out_of_scope), (1, 0)); + let manifest = load_manifest().unwrap(); + assert!( + !manifest["claude"].contains_key("bbbb2222-x"), + "pruned projects must not be enumerated or recorded" + ); + + // Unscoped sync picks up the rest. + let (_, full) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + assert_eq!((full.new, full.unchanged), (1, 1)); + }); + } + + fn codex_bundle(home: &Path, cwd: &str) -> HarnessBundle { + let codex_dir = home.join(".codex"); + let dir = codex_dir.join("sessions/2026/05/07"); + std::fs::create_dir_all(&dir).unwrap(); + let meta = format!( + r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"00000000-0000-0000-0000-0000000000aa","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"# + ); + let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#; + std::fs::write( + dir.join("rollout-2026-05-07T00-00-00-00000000-0000-0000-0000-0000000000aa.jsonl"), + format!("{meta}\n{user}\n"), + ) + .unwrap(); + let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir); + HarnessBundle { + codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)), + ..Default::default() + } + } + + #[test] + fn out_of_scope_codex_peek_is_memoized_then_scope_match_derives() { + with_cfg(|home| { + let bundle = codex_bundle(home, "/work/proj"); + + // cwd lives outside the constraint: one bounded peek, a + // known-but-uncached record, no derive. + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Codex], + Some(Path::new("/elsewhere")), + &mut (), + ) + .unwrap()[0]; + assert_eq!((out.new, out.out_of_scope), (0, 1)); + let rec = + load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"].clone(); + assert_eq!( + rec.path.as_deref(), + Some("/work/proj"), + "peeked cwd memoized" + ); + assert!(rec.cache_id.is_none(), "known, not materialized"); + + // Matching constraint: the memoized record answers the scope + // question and the artifact derives. + let (_, hit) = sync_bundle( + &bundle, + &[ArtifactType::Codex], + Some(Path::new("/work/proj")), + &mut (), + ) + .unwrap()[0]; + assert_eq!((hit.new, hit.updated, hit.out_of_scope), (0, 1, 0)); + let rec = + load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"].clone(); + assert!(rec.cache_id.is_some(), "materialized now"); + assert_eq!( + rec.path.as_deref(), + Some("/work/proj"), + "deriving must not clobber the memoized peeked cwd" + ); + }); + } + + fn copilot_bundle(home: &Path, id: &str, cwd: &str) -> HarnessBundle { + let copilot_dir = home.join(".copilot"); + let dir = copilot_dir.join("session-state").join(id); + std::fs::create_dir_all(&dir).unwrap(); + let start = format!( + r#"{{"type":"session.start","timestamp":"2026-07-01T00:00:00Z","data":{{"copilotVersion":"1.0.67","context":{{"cwd":"{cwd}"}}}}}}"# + ); + let user = + r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#; + std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap(); + let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + HarnessBundle { + copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), + ..Default::default() + } + } + + #[test] + fn copilot_syncs_and_scopes_via_memoized_peek() { + with_cfg(|home| { + let bundle = copilot_bundle(home, "sess-cp", "/work/proj"); + + // Out-of-scope first: one peek, a known record with the cwd. + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/elsewhere")), + &mut (), + ) + .unwrap()[0]; + assert_eq!((out.new, out.out_of_scope), (0, 1)); + let rec = load_manifest().unwrap()["copilot"]["sess-cp"].clone(); + assert_eq!(rec.path.as_deref(), Some("/work/proj")); + assert!(rec.cache_id.is_none()); + + // In scope: derives; then a plain re-sync is a no-op. + let (_, hit) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/work")), + &mut (), + ) + .unwrap()[0]; + assert_eq!((hit.updated, hit.out_of_scope), (1, 0)); + let (_, again) = + sync_bundle(&bundle, &[ArtifactType::Copilot], None, &mut ()).unwrap()[0]; + assert_eq!(again.unchanged, 1); + }); + } + #[test] fn evicted_cache_entry_rematerializes_on_next_sync() { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id .clone() @@ -801,7 +999,8 @@ mod tests { .is_none() ); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, outcome) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!((outcome.new, outcome.updated), (0, 1)); assert!( crate::cache::cache_path(&cache_id).unwrap().exists(), @@ -815,7 +1014,7 @@ mod tests { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id .clone() @@ -825,7 +1024,8 @@ mod tests { // materialization, but sync verifies the doc exists. let doc = crate::cache::cache_path(&cache_id).unwrap(); std::fs::remove_file(&doc).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap()[0]; + let (_, outcome) = + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; assert_eq!((outcome.new, outcome.updated), (0, 1)); assert!(doc.exists()); }); @@ -848,7 +1048,7 @@ mod tests { .is_none() ); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); let cache_id = fresh_cache_id( &bundle, ArtifactType::Claude, @@ -874,7 +1074,7 @@ mod tests { ) .is_none() ); - sync_bundle(&bundle, &[ArtifactType::Claude], &mut ()).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); assert!( fresh_cache_id( &bundle, @@ -900,6 +1100,42 @@ mod tests { }); } + #[test] + fn copilot_peek_accepts_top_level_cwd() { + with_cfg(|home| { + // Older CLIs store cwd at the payload top level, no + // `context` object — the peek must still find it. + let copilot_dir = home.join(".copilot"); + let dir = copilot_dir.join("session-state").join("sess-legacy"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"session.start","data":{"cwd":"/work/proj"}}"#, + "\n", + r#"{"type":"user.message","data":{"content":"hi"}}"#, + "\n" + ), + ) + .unwrap(); + let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + let bundle = HarnessBundle { + copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), + ..Default::default() + }; + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/elsewhere")), + &mut (), + ) + .unwrap()[0]; + assert_eq!(out.out_of_scope, 1); + let rec = load_manifest().unwrap()["copilot"]["sess-legacy"].clone(); + assert_eq!(rec.path.as_deref(), Some("/work/proj")); + }); + } + #[test] fn newest_first_orders_by_mtime_with_unstamped_last() { let mut old = make_ref(ArtifactType::Claude, "old"); diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index ba76608c..ae8b1319 100644 --- a/crates/path-cli/src/sync/sources.rs +++ b/crates/path-cli/src/sync/sources.rs @@ -9,6 +9,7 @@ use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; +use std::path::{Path, PathBuf}; use crate::artifact::{ArtifactRef, ArtifactType, claude_chain_stamp, stat_stamp}; use crate::derive::{self, DerivedDoc}; @@ -25,9 +26,10 @@ pub(crate) type Stamp = (Option>, Option); /// One provider, as the sync engine sees it. pub(crate) trait ArtifactSource { /// Enumerate this provider's artifacts with stat-level - /// fingerprints. Never reads session bodies. Listing errors warn - /// and skip so one broken provider can't block a run. - fn enumerate(&self) -> Vec; + /// fingerprints, pruning to `project_under` when the provider's + /// listing is path-keyed. Never reads session bodies. Listing + /// errors warn and skip so one broken provider can't block a run. + fn enumerate(&self, project_under: Option<&Path>) -> Vec; /// Stat-level fingerprint for a single artifact, resolved /// directly — the same stat targets as [`Self::enumerate`], so the @@ -39,6 +41,21 @@ pub(crate) trait ArtifactSource { /// through the same manager it was enumerated from, so listing and /// derivation always agree on provider roots. fn derive(&self, artifact: &ArtifactRef) -> Result; + + /// Bounded peek at the directory an artifact lives in, for + /// providers whose cheap listing doesn't carry it (the cwd lives + /// inside the session file). The engine memoizes the answer into + /// the manifest record, so this runs at most once per artifact. + fn peek_dir(&self, _id: &str) -> Option { + None + } + + /// Whether `dir` lies under `project_under` in this provider's key + /// space. Providers whose keys are lossy dir encodings (claude, + /// pi) compare in the encoded space instead of as real paths. + fn in_scope(&self, dir: &str, project_under: &Path) -> bool { + dir_in_scope(dir, project_under) + } } /// The source for `t`'s artifacts, borrowing its manager from @@ -69,6 +86,18 @@ fn require_path(artifact: &ArtifactRef) -> Result<&str> { .ok_or_else(|| anyhow!("artifact {} has no path", artifact.id)) } +fn canonicalize_or_self(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +/// Subtree check for a real filesystem path. Canonicalizes both +/// sides, but also accepts the raw parent so a not-yet-resolvable +/// constraint (or an unresolvable dir) still matches literally. +fn dir_in_scope(dir: &str, project_under: &Path) -> bool { + let d = canonicalize_or_self(Path::new(dir)); + d.starts_with(canonicalize_or_self(project_under)) || d.starts_with(project_under) +} + // ── claude ───────────────────────────────────────────────────────── struct ClaudeSource<'a>(&'a toolpath_claude::ClaudeConvo); @@ -79,7 +108,7 @@ impl ArtifactSource for ClaudeSource<'_> { /// segment and its id is rotation-stable; the fingerprint covers /// the whole chain (see `claude_chain_stamp`) because appends land /// in the newest segment, not the head file. - fn enumerate(&self) -> Vec { + fn enumerate(&self, project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let projects = match self.0.list_projects() { Ok(ps) => ps, @@ -90,6 +119,11 @@ impl ArtifactSource for ClaudeSource<'_> { } }; for project in projects { + if let Some(project_under) = project_under + && !claude_project_in_scope(&project, project_under) + { + continue; + } let heads = match self.0.list_conversations(&project) { Ok(h) => h, Err(e) => { @@ -118,6 +152,27 @@ impl ArtifactSource for ClaudeSource<'_> { fn derive(&self, artifact: &ArtifactRef) -> Result { derive::derive_claude_session_with(self.0, require_path(artifact)?, &artifact.id) } + + fn in_scope(&self, dir: &str, project_under: &Path) -> bool { + claude_project_in_scope(dir, project_under) + } +} + +/// Claude's project-dir slugs are lossy — '/', '_', and '.' all +/// became '-', and un-sanitizing only restores '/'. Comparing real +/// paths therefore misfilters any project containing '.' or '_'; +/// compare in slug space instead, where '/' boundaries are '-'. +fn claude_project_in_scope(project: &str, project_under: &Path) -> bool { + fn slug(s: &str) -> String { + s.replace(['/', '_', '.'], "-") + } + let p = slug(project); + [ + slug(&project_under.to_string_lossy()), + slug(&canonicalize_or_self(project_under).to_string_lossy()), + ] + .iter() + .any(|parent| p == *parent || p.starts_with(&format!("{parent}-"))) } // ── gemini ───────────────────────────────────────────────────────── @@ -128,7 +183,7 @@ impl ArtifactSource for GeminiSource<'_> { /// Session entries via a bounded identity peek (`toolpath-gemini` /// reads at most the first 4 KiB of a main file); the fingerprint /// stats the main file (or the orphan sub-agent directory). - fn enumerate(&self) -> Vec { + fn enumerate(&self, project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let projects = match self.0.list_projects() { Ok(ps) => ps, @@ -139,6 +194,11 @@ impl ArtifactSource for GeminiSource<'_> { } }; for project in projects { + if let Some(project_under) = project_under + && !dir_in_scope(&project, project_under) + { + continue; + } let entries = match self.0.resolver().list_session_entries(&project) { Ok(entries) => entries, Err(e) => { @@ -181,7 +241,7 @@ impl ArtifactSource for CodexSource<'_> { /// Rollout files, stat-only. The artifact id is the trailing UUID of /// the filename stem (`rollout--`); `read_session` /// accepts either the UUID or the full stem, so the fallback is safe. - fn enumerate(&self) -> Vec { + fn enumerate(&self, _project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let files = match self.0.io().list_rollout_files() { Ok(f) => f, @@ -216,6 +276,18 @@ impl ArtifactSource for CodexSource<'_> { fn derive(&self, artifact: &ArtifactRef) -> Result { derive::derive_codex_session_with(self.0, &artifact.id) } + + /// The rollout's first line is `session_meta`; its payload carries + /// cwd directly — one bounded read. + fn peek_dir(&self, id: &str) -> Option { + use std::io::{BufRead, BufReader}; + let file = self.0.resolver().find_rollout_file(id).ok()?; + let f = std::fs::File::open(file).ok()?; + let mut line = String::new(); + BufReader::new(f).read_line(&mut line).ok()?; + let v: serde_json::Value = serde_json::from_str(&line).ok()?; + Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()) + } } // ── opencode ─────────────────────────────────────────────────────── @@ -225,7 +297,7 @@ struct OpencodeSource<'a>(&'a toolpath_opencode::OpencodeConvo); impl ArtifactSource for OpencodeSource<'_> { /// One header-only `SELECT` — `time_updated` is the fingerprint; no /// message bodies are loaded. - fn enumerate(&self) -> Vec { + fn enumerate(&self, _project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let sessions = match self.0.io().list_sessions(None) { Ok(s) => s, @@ -267,7 +339,7 @@ impl ArtifactSource for CursorSource<'_> { /// check) — `lastUpdatedAt` is the fingerprint. Bubble-less drafts /// are skipped; unlike `share`, composers without a workspace are /// included, since sync doesn't need to rank them by project. - fn enumerate(&self) -> Vec { + fn enumerate(&self, _project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let listings = match self.0.io().list_composers() { Ok(l) => l, @@ -314,7 +386,7 @@ impl ArtifactSource for PiSource<'_> { /// Session files stat-only; the id comes from a one-line header /// peek, falling back to the filename stem's `_` /// shape — the same resolution `read_session` accepts. - fn enumerate(&self) -> Vec { + fn enumerate(&self, project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let projects = match self.0.list_projects() { Ok(ps) => ps, @@ -325,6 +397,11 @@ impl ArtifactSource for PiSource<'_> { } }; for project in projects { + if let Some(project_under) = project_under + && !pi_project_in_scope(&project, project_under) + { + continue; + } let files = match toolpath_pi::reader::list_session_files(self.0.resolver(), &project) { Ok(f) => f, Err(e) => { @@ -373,6 +450,27 @@ impl ArtifactSource for PiSource<'_> { fn derive(&self, artifact: &ArtifactRef) -> Result { derive::derive_pi_session_with(self.0, require_path(artifact)?, &artifact.id) } + + fn in_scope(&self, dir: &str, project_under: &Path) -> bool { + pi_project_in_scope(dir, project_under) + } +} + +/// Pi's session-dir names encode '/' as '-', and decoding restores +/// every '-' to '/', so a decoded project string is wrong for any +/// real path containing a hyphen. Compare in the encoded space, +/// where '/' boundaries are '-'. +fn pi_project_in_scope(project: &str, project_under: &Path) -> bool { + fn enc(s: &str) -> String { + s.replace('/', "-") + } + let p = enc(project); + [ + enc(&project_under.to_string_lossy()), + enc(&canonicalize_or_self(project_under).to_string_lossy()), + ] + .iter() + .any(|parent| p == *parent || p.starts_with(&format!("{parent}-"))) } // ── copilot ──────────────────────────────────────────────────────── @@ -384,7 +482,7 @@ impl ArtifactSource for CopilotSource<'_> { /// `/events.jsonl` under `session-state/` (or its legacy /// sibling); the directory name is the id and the events file is /// the fingerprint target. - fn enumerate(&self) -> Vec { + fn enumerate(&self, _project_under: Option<&Path>) -> Vec { let mut out = Vec::new(); let mut seen = std::collections::HashSet::new(); let dirs = [ @@ -424,6 +522,30 @@ impl ArtifactSource for CopilotSource<'_> { fn derive(&self, artifact: &ArtifactRef) -> Result { derive::derive_copilot_session_with(self.0, &artifact.id) } + + /// `session.start` is usually first but the format tolerates it + /// appearing later, and older CLIs store cwd at the top of the + /// payload instead of under `context` — scan a bounded number of + /// lines and accept both shapes, mirroring toolpath-copilot's own + /// tolerance. + fn peek_dir(&self, id: &str) -> Option { + use std::io::{BufRead, BufReader}; + let file = self.0.resolver().events_file(id).ok()?; + let f = std::fs::File::open(file).ok()?; + BufReader::new(f).lines().take(10).find_map(|line| { + let v: serde_json::Value = serde_json::from_str(&line.ok()?).ok()?; + ["data", "payload"].iter().find_map(|env| { + let payload = v.get(env)?; + [payload.get("context").unwrap_or(payload), payload] + .iter() + .find_map(|scope| { + ["cwd", "workingDirectory", "working_dir"] + .iter() + .find_map(|k| Some(scope.get(k)?.as_str()?.to_string())) + }) + }) + }) + } } #[cfg(test)] @@ -446,4 +568,23 @@ mod tests { "git is recorded by `p import`, never enumerated or derived by sync" ); } + + #[test] + fn pi_scope_matching_survives_hyphenated_paths() { + // decode_project turns the dir-encoded '-' back into '/', so a + // real path /Users/b/my-repo arrives as /Users/b/my/repo; the + // comparator must match it against the real constraint anyway. + assert!(pi_project_in_scope( + "/Users/b/my/repo", + Path::new("/Users/b/my-repo") + )); + assert!(pi_project_in_scope( + "/Users/b/my/repo/sub", + Path::new("/Users/b/my-repo") + )); + assert!(!pi_project_in_scope( + "/Users/b/other", + Path::new("/Users/b/my-repo") + )); + } } diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index f5c22b36..af537c1b 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -903,6 +903,30 @@ fn share_records_manifest_so_sync_skips() { .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); } +#[test] +fn cache_sync_project_under_limits_ingestion() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude", "--project-under", "/nowhere"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 0 unchanged")); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude", "--project-under"]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged")); +} + #[test] fn share_uploads_cached_doc_when_source_unchanged() { let (home, session_file) = claude_home_fixture(); diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index 5ebbf029..05dc7b1a 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -600,3 +600,26 @@ fn input_only_query_never_touches_the_cache() { .stdout(predicate::str::contains("true")); assert!(!cfg.path().join("manifest.json").exists()); } + +#[test] +fn query_project_under_scopes_sync_and_read() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + // Constraint matches the session's project: ingested and read. + fixture_query( + &home, + cfg.path(), + ["--project-under", project.to_str().unwrap(), "length > 0"], + ) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced claude: 1 new")); + + // Constraint elsewhere: nothing read, nothing new ingested. + fixture_query(&home, cfg.path(), ["--project-under", "/nowhere", "length"]) + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains("synced").not()); +}