From ec46a35f0601d66320ffbbf38a3876800f6c0036 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Jul 2026 16:39:46 -0400 Subject: [PATCH 1/4] feat(cli): --parent-dir scoped sync and query --parent-dir / -d on p cache sync and path query restricts ingestion (and the query's reads) to artifacts under a directory. The stat gate always runs first; only artifacts that would cost a derive get the scope check, with one-line cwd peeks for codex/copilot memoized into the manifest record. Claude and pi compare in their lossy encoded key spaces. Out-of-scope artifacts are recorded as known-but-uncached and tallied separately, never touching a materialized record's stamp. --- CHANGELOG.md | 14 ++ CLAUDE.md | 4 +- crates/path-cli/src/cmd_cache.rs | 8 +- crates/path-cli/src/cmd_query.rs | 8 +- crates/path-cli/src/query/mod.rs | 34 +++- crates/path-cli/src/sync/engine.rs | 280 ++++++++++++++++++++++++--- crates/path-cli/src/sync/sources.rs | 161 ++++++++++++++- crates/path-cli/tests/integration.rs | 24 +++ crates/path-cli/tests/query.rs | 23 +++ 9 files changed, 503 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 285904b9..2299752c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,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. + - `--parent-dir ` / `-d` on `p cache sync` and `path query` + restricts ingestion (and the query's reads) to artifacts 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. ## One artifact-type layer and per-session imports — 2026-07-16 diff --git a/CLAUDE.md b/CLAUDE.md index 30f59e7c..db190bfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,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 -d ~/work/proj # only artifacts under this directory # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json @@ -138,6 +139,7 @@ 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. +# --parent-dir/-d scopes both the sync and the read to 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:")))' @@ -280,5 +282,5 @@ 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/sync.rs`: `ArtifactType` + `ArtifactRef`; `sync/engine.rs`: manifest + ingestion loop; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive — with one impl per provider, so the engine never matches on artifact type) 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 `cmd_import.rs`). Manifest at `~/.toolpath/sync.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 (`sync.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/sync.rs`: `ArtifactType` + `ArtifactRef`; `sync/engine.rs`: manifest + ingestion loop; `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) 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 `cmd_import.rs`). Manifest at `~/.toolpath/sync.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 (`sync.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 `--parent-dir` 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). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts 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. - `ArtifactType` (`crates/path-cli/src/sync.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 (`cmd_share.rs`) 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 0373e894..f2789b8b 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -30,6 +30,12 @@ pub enum CacheOp { /// Artifact types to sync (default: every agent harness) #[arg(value_enum)] types: Vec, + + /// Only ingest artifacts living under this directory (subtree + /// match). Out-of-scope artifacts are noted in the manifest but + /// not derived. + #[arg(long, short = 'd')] + parent_dir: Option, }, } @@ -38,7 +44,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 } => crate::sync::run(types), + CacheOp::Sync { types, parent_dir } => crate::sync::run(types, parent_dir), } } diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index ae809fe2..909f2b49 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -42,6 +42,11 @@ pub struct QueryArgs { #[arg(long)] project: Option, + /// Keep only paths whose base lives under this directory (subtree + /// match), and scope the implicit sync to it likewise. + #[arg(long, short = 'd')] + parent_dir: Option, + /// Keep only paths whose meta.kind matches this selector /// (semver prefix, e.g. `agent-coding-session` or `…/v1.0`). #[arg(long)] @@ -103,6 +108,7 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { ids: args.ids, inputs: args.input, project: args.project, + parent_dir: args.parent_dir, kind: args.kind, }; @@ -123,7 +129,7 @@ fn sync_query_scope(args: &QueryArgs) { return; } let bundle = crate::cmd_share::HarnessBundle::from_environment(); - match crate::sync::sync_bundle(&bundle, &types) { + match crate::sync::sync_bundle(&bundle, &types, args.parent_dir.as_deref()) { 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 6d5d2cfc..ecc002fb 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, + /// `--parent-dir`: keep only paths whose `base` lives under this + /// directory (subtree match). + pub parent_dir: 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 parent_dir = scope.parent_dir.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(), + parent_dir.as_deref(), &mut steps, ); drop(graph); @@ -231,6 +236,7 @@ fn wrap_graph( graph: &Graph, kind_sel: Option<&KindSelector>, project: Option<&FsPath>, + parent_dir: Option<&FsPath>, out: &mut Vec, ) { for entry in &graph.paths { @@ -249,6 +255,11 @@ fn wrap_graph( { continue; } + if let Some(dir) = parent_dir + && !path_matches_parent_dir(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_parent_dir(path: &Path, parent_dir: &FsPath) -> bool { + base_fs_path(path).is_some_and(|p| p.starts_with(parent_dir)) +} + +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, + parent_dir: 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 e4d884ca..5ca07106 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 super::{ArtifactRef, ArtifactType}; @@ -18,7 +18,8 @@ const MANIFEST_FILE: &str = "sync.json"; /// 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 @@ -52,19 +53,21 @@ pub(crate) struct SyncOutcome { pub(crate) updated: usize, pub(crate) unchanged: usize, pub(crate) failed: usize, + /// Artifacts needing work that a `--parent-dir` constraint excluded. + pub(crate) out_of_scope: usize, } impl SyncOutcome { fn total(&self) -> usize { - self.new + self.updated + self.unchanged + self.failed + self.new + self.updated + self.unchanged + self.failed + self.out_of_scope } } -pub(crate) fn run(types: Vec) -> Result<()> { +pub(crate) fn run(types: Vec, parent_dir: Option) -> Result<()> { let explicit = !types.is_empty(); let types = resolve_types(&types); let bundle = HarnessBundle::from_environment(); - let outcomes = sync_bundle(&bundle, &types)?; + let outcomes = sync_bundle(&bundle, &types, parent_dir.as_deref())?; eprint!("{}", render_summary(&outcomes, explicit)); Ok(()) } @@ -94,6 +97,7 @@ fn resolve_types(args: &[ArtifactType]) -> Vec { pub(crate) fn sync_bundle( bundle: &HarnessBundle, types: &[ArtifactType], + parent_dir: Option<&Path>, ) -> Result> { let manifest = load_manifest()?; let mut out = Vec::with_capacity(types.len()); @@ -105,12 +109,12 @@ pub(crate) fn sync_bundle( out.push((artifact_type, SyncOutcome::default())); continue; }; - let artifacts = source.enumerate(); + let artifacts = source.enumerate(parent_dir); let records = manifest .get(artifact_type.name()) .cloned() .unwrap_or_default(); - let outcome = sync_artifacts(source.as_ref(), &artifacts, &records)?; + let outcome = sync_artifacts(source.as_ref(), &artifacts, &records, parent_dir)?; out.push((artifact_type, outcome)); } Ok(out) @@ -174,6 +178,7 @@ fn sync_artifacts( source: &dyn ArtifactSource, artifacts: &[ArtifactRef], records: &BTreeMap, + parent_dir: Option<&Path>, ) -> Result { let mut outcome = SyncOutcome::default(); // Evaluate the stat gate once per artifact: the pass feeds both the @@ -206,8 +211,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(parent_dir) = parent_dir { + 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, parent_dir)); + 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; + } + progress.tick(); + if unflushed >= CHECKPOINT_EVERY { + 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() @@ -333,6 +378,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() { @@ -615,7 +663,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")); @@ -634,7 +682,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]).unwrap(); + let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); assert_eq!(outcomes.len(), 1); let (_, first) = outcomes[0]; assert_eq!( @@ -658,7 +706,7 @@ mod tests { "cache doc must exist for {cache_id}" ); - let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( (second.new, second.updated, second.unchanged, second.failed), (0, 0, 2, 0) @@ -671,7 +719,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]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id @@ -689,7 +737,7 @@ mod tests { body.push('\n'); std::fs::write(&file, body).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( ( outcome.new, @@ -712,7 +760,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]).unwrap(); + let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex], None).unwrap(); assert_eq!(outcomes[0].1, SyncOutcome::default()); assert!( load_manifest().unwrap().is_empty(), @@ -726,13 +774,13 @@ 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]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).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]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!((outcome.new, outcome.failed), (1, 0)); }); } @@ -743,10 +791,11 @@ 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(source.as_ref(), &artifacts, &BTreeMap::new()).unwrap(); + let outcome = + sync_artifacts(source.as_ref(), &artifacts, &BTreeMap::new(), None).unwrap(); assert_eq!((outcome.new, outcome.failed), (1, 1)); let records = &load_manifest().unwrap()["claude"]; assert!(records.contains_key("sess-aaa")); @@ -762,7 +811,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]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id .clone() @@ -783,7 +832,7 @@ mod tests { ) .unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( (outcome.new, outcome.updated, outcome.unchanged), (0, 1, 0), @@ -800,7 +849,7 @@ mod tests { ); // And the grown chain settles: a third sync is a no-op. - let (_, again) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, again) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!((again.updated, again.unchanged), (0, 1)); }); } @@ -810,7 +859,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]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); // A record whose stamps are all None (stat failed when it // was written) must not match a stub whose stat also @@ -821,7 +870,7 @@ mod tests { rec.size = None; let artifact = make_ref(ArtifactType::Claude, "sess-aaa"); let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap(); - let outcome = sync_artifacts(source.as_ref(), &[artifact], &records).unwrap(); + let outcome = sync_artifacts(source.as_ref(), &[artifact], &records, None).unwrap(); assert_eq!((outcome.updated, outcome.unchanged), (1, 0)); }); } @@ -847,7 +896,7 @@ 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]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( ( outcome.new, @@ -860,12 +909,144 @@ mod tests { }); } + #[test] + fn parent_dir_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")), + ) + .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).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")), + ) + .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")), + ) + .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")), + ) + .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"))) + .unwrap()[0]; + assert_eq!((hit.updated, hit.out_of_scope), (1, 0)); + let (_, again) = sync_bundle(&bundle, &[ArtifactType::Copilot], None).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]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id .clone() @@ -880,7 +1061,7 @@ mod tests { .is_none() ); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!((outcome.new, outcome.updated), (0, 1)); assert!( crate::cmd_cache::cache_path(&cache_id).unwrap().exists(), @@ -894,7 +1075,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]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id .clone() @@ -904,7 +1085,7 @@ mod tests { // materialization, but sync verifies the doc exists. let doc = crate::cmd_cache::cache_path(&cache_id).unwrap(); std::fs::remove_file(&doc).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!((outcome.new, outcome.updated), (0, 1)); assert!(doc.exists()); }); @@ -927,7 +1108,7 @@ mod tests { .is_none() ); - sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); let cache_id = fresh_cache_id( &bundle, ArtifactType::Claude, @@ -953,7 +1134,7 @@ mod tests { ) .is_none() ); - sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); assert!( fresh_cache_id( &bundle, @@ -979,6 +1160,41 @@ 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")), + ) + .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"); @@ -1028,6 +1244,7 @@ mod tests { updated: 1, unchanged: 3, failed: 0, + out_of_scope: 0, }, ), (ArtifactType::Cursor, SyncOutcome::default()), @@ -1049,6 +1266,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/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index be0dcafe..8197c4e8 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 super::{ArtifactRef, ArtifactType, claude_chain_stamp, codex_artifact_id, stat_stamp}; use crate::cmd_import::{self as imp, 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 `parent_dir` 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, parent_dir: 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 `parent_dir` 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, parent_dir: &Path) -> bool { + dir_in_scope(dir, parent_dir) + } } /// 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, parent_dir: &Path) -> bool { + let d = canonicalize_or_self(Path::new(dir)); + d.starts_with(canonicalize_or_self(parent_dir)) || d.starts_with(parent_dir) +} + // ── 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, parent_dir: 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(parent_dir) = parent_dir + && !claude_project_in_scope(&project, parent_dir) + { + 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 { imp::derive_claude_session_with(self.0, require_path(artifact)?, &artifact.id) } + + fn in_scope(&self, dir: &str, parent_dir: &Path) -> bool { + claude_project_in_scope(dir, parent_dir) + } +} + +/// 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, parent_dir: &Path) -> bool { + fn slug(s: &str) -> String { + s.replace(['/', '_', '.'], "-") + } + let p = slug(project); + [ + slug(&parent_dir.to_string_lossy()), + slug(&canonicalize_or_self(parent_dir).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, parent_dir: 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(parent_dir) = parent_dir + && !dir_in_scope(&project, parent_dir) + { + 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, _parent_dir: 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 { imp::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, _parent_dir: 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, _parent_dir: 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, parent_dir: 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(parent_dir) = parent_dir + && !pi_project_in_scope(&project, parent_dir) + { + 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 { imp::derive_pi_session_with(self.0, require_path(artifact)?, &artifact.id) } + + fn in_scope(&self, dir: &str, parent_dir: &Path) -> bool { + pi_project_in_scope(dir, parent_dir) + } +} + +/// 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, parent_dir: &Path) -> bool { + fn enc(s: &str) -> String { + s.replace('/', "-") + } + let p = enc(project); + [ + enc(&parent_dir.to_string_lossy()), + enc(&canonicalize_or_self(parent_dir).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, _parent_dir: 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 { imp::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 82faff9f..546b558f 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_parent_dir_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", "-d", "/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", "-d"]) + .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 8bd0f48f..30559967 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("sync.json").exists()); } + +#[test] +fn query_parent_dir_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(), + ["--parent-dir", 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(), ["--parent-dir", "/nowhere", "length"]) + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains("synced").not()); +} From 5e3772d0da818f8cf0feb1d5c205971b5dddd912 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 20 Jul 2026 12:43:07 -0400 Subject: [PATCH 2/4] fix(cli): gate cmd_cache PathBuf import off emscripten --- crates/path-cli/src/cmd_cache.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index c75ca265..2a96e1e9 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -3,6 +3,7 @@ use anyhow::Result; use clap::Subcommand; +#[cfg(not(target_os = "emscripten"))] use std::path::PathBuf; use crate::cache::{list_cached, remove_cached}; From 5f8a8d285de0a6c31b90c6bc2bd4a46b7a331617 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 23 Jul 2026 15:21:32 -0400 Subject: [PATCH 3/4] docs: refresh path-cli test counts for the parent-dir branch --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fae0c112..e9dd8fc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,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` From 5d94936852442aa601a813a5261a84c0a3303768 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 23 Jul 2026 15:32:44 -0400 Subject: [PATCH 4/4] fix(cli): follow the checkpoint-const rename in the scope-gated sync loop --- crates/path-cli/src/sync/engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index 924a3a05..3dac1d93 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -242,7 +242,7 @@ fn sync_artifacts( unflushed += 1; } progress.tick(); - if unflushed >= CHECKPOINT_EVERY { + if unflushed >= MANIFEST_CHECKPOINT_EVERY_WRITES { flush_writes(&mut writes)?; unflushed = 0; }