diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2b17779..dc14f8f9 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.
- **`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 b604c657..8cfcfdb5 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:")))'
@@ -210,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`
@@ -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/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `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 `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; `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 `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 `--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/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 dd86008a..5624e902 100644
--- a/crates/path-cli/src/cmd_cache.rs
+++ b/crates/path-cli/src/cmd_cache.rs
@@ -3,6 +3,8 @@
use anyhow::Result;
use clap::Subcommand;
+#[cfg(not(target_os = "emscripten"))]
+use std::path::PathBuf;
use crate::cache::{list_cached, remove_cached};
@@ -22,6 +24,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,
},
}
@@ -30,7 +38,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 d6ac81e0..ea24e593 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::harness::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 08e079db..635c81bb 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 10dec31b..3dac1d93 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,19 +58,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(())
}
@@ -99,6 +102,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());
@@ -110,12 +114,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)
@@ -172,6 +176,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
@@ -204,8 +209,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 >= 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()
@@ -331,6 +376,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() {
@@ -613,7 +661,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"));
@@ -632,7 +680,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!(
@@ -656,7 +704,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)
@@ -669,7 +717,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
@@ -687,7 +735,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,
@@ -710,7 +758,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(),
@@ -724,13 +772,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));
});
}
@@ -741,10 +789,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"));
@@ -760,7 +809,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()
@@ -781,7 +830,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),
@@ -798,7 +847,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));
});
}
@@ -808,7 +857,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
@@ -819,7 +868,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));
});
}
@@ -845,7 +894,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,
@@ -858,12 +907,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()
@@ -878,7 +1059,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::cache::cache_path(&cache_id).unwrap().exists(),
@@ -892,7 +1073,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()
@@ -902,7 +1083,7 @@ 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]).unwrap()[0];
+ let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0];
assert_eq!((outcome.new, outcome.updated), (0, 1));
assert!(doc.exists());
});
@@ -925,7 +1106,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,
@@ -951,7 +1132,7 @@ mod tests {
)
.is_none()
);
- sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap();
+ sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap();
assert!(
fresh_cache_id(
&bundle,
@@ -977,6 +1158,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");
@@ -1026,6 +1242,7 @@ mod tests {
updated: 1,
unchanged: 3,
failed: 0,
+ out_of_scope: 0,
},
),
(ArtifactType::Cursor, SyncOutcome::default()),
@@ -1047,6 +1264,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 ba76608c..4ae87617 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 `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 {
derive::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 {
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, _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 {
derive::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 {
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..836cf445 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 5ebbf029..80d83a12 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_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());
+}