diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 5df70cc5..2839ea96 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -165,17 +165,19 @@ pub struct WebFetchConfig { } /// Memory subsystem configuration. Presence in the manifest enables -/// memory; the workspace root defaults to the Pod's pwd unless an -/// explicit override is given. +/// memory; `workspace_root` pins the memory workspace explicitly. When it +/// is absent, memory resolution searches upward from the Pod's pwd for a +/// `.yoi/memory` marker rather than treating `.yoi` project records alone +/// as a memory root. /// /// All fields are `Option`; defaults are applied at the consumer /// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple /// (`upper.x.or(self.x)`) without a separate partial/resolved split. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MemoryConfig { - /// Override for the workspace root. When `None`, the Pod's pwd - /// (resolved at construction time) is used. When set, must be an - /// absolute path. + /// Override for the memory workspace root. When `None`, consumers resolve + /// the root from their default path and ancestor `.yoi/memory` markers. + /// When set, must be an absolute path. #[serde(default)] pub workspace_root: Option, /// Maximum number of records returned by `MemoryQuery` / diff --git a/crates/memory/src/workspace.rs b/crates/memory/src/workspace.rs index 09523311..4a03edca 100644 --- a/crates/memory/src/workspace.rs +++ b/crates/memory/src/workspace.rs @@ -1,10 +1,9 @@ //! Workspace-level path layout for the memory subsystem. //! -//! `WorkspaceLayout` carries the workspace root (typically the Pod's -//! pwd). All yoi-managed content lives under the conventional -//! `/.yoi/` subdirectory — the same place that holds -//! `profiles.toml`, `prompts/`, workflow, knowledge, and generated -//! memory. The trees inside it: +//! `WorkspaceLayout` carries the root used by the memory subsystem. +//! All yoi-managed memory content lives under the conventional +//! `/.yoi/` subdirectory — alongside workspace project records +//! such as workflow and generated durable memory. The trees inside it: //! //! - `/.yoi/workflow/.md` //! - `/.yoi/knowledge/.md` @@ -18,9 +17,9 @@ //! Workflows are human-managed and live one level up under //! `.yoi/workflow/`. //! -//! Configuring `[memory]` with an empty body is therefore sufficient -//! for any workspace that already uses the `.yoi/` convention; no -//! `workspace_root` override is needed. +//! `memory.workspace_root` pins this root explicitly. Without an explicit +//! root, resolution searches upward from the Pod pwd for a `.yoi/memory` +//! marker; `.yoi` project records alone are not a memory marker. use std::path::{Path, PathBuf}; @@ -82,17 +81,26 @@ impl WorkspaceLayout { Self { root: root.into() } } - /// Resolve a layout from a `MemoryConfig`, falling back to - /// `default_root` (typically the Pod's pwd) when the manifest does - /// not pin `workspace_root` explicitly. Single source of truth for - /// the `workspace_root.unwrap_or(pwd)` convention used across the - /// codebase (controller wiring, scope-deny build, system-prompt - /// resident-injection). + /// Resolve a layout from a `MemoryConfig`. + /// + /// An explicit `memory.workspace_root` is honored exactly. Without an + /// explicit root, resolution searches `default_root` and its ancestors for + /// the nearest `.yoi/memory` directory. This keeps child worktrees that + /// contain `.yoi` project records such as tickets or workflows from + /// becoming independent memory roots merely because they contain `.yoi`. + /// + /// If no memory marker exists, this falls back to `default_root` because + /// existing call sites require a concrete layout. That fallback is a + /// no-marker compatibility path, not a `.yoi` marker interpretation; it + /// must not be used as evidence that `.yoi` alone enables repo-local + /// memory. pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self { - let root = cfg - .workspace_root - .clone() - .unwrap_or_else(|| default_root.to_path_buf()); + if let Some(root) = &cfg.workspace_root { + return Self::new(root.clone()); + } + + let root = + find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf()); Self::new(root) } @@ -225,6 +233,13 @@ impl WorkspaceLayout { } } +fn find_memory_marker_root(default_root: &Path) -> Option { + default_root + .ancestors() + .find(|ancestor| ancestor.join(YOI_DIR).join(MEMORY_DIR).is_dir()) + .map(Path::to_path_buf) +} + fn classify_kinded_md( rel: &Path, kind: RecordKind, @@ -257,6 +272,7 @@ fn classify_kinded_md( mod tests { use super::*; use std::path::PathBuf; + use tempfile::TempDir; fn layout() -> WorkspaceLayout { WorkspaceLayout::new(PathBuf::from("/ws")) @@ -379,9 +395,45 @@ mod tests { } #[test] - fn resolve_falls_back_to_default_when_workspace_root_missing() { + fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let child = workspace.join(".worktree/child"); + std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap(); + std::fs::create_dir_all(&child).unwrap(); + let cfg = manifest::MemoryConfig::default(); - let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback")); - assert_eq!(layout.root(), Path::new("/fallback")); + let layout = WorkspaceLayout::resolve(&cfg, &child); + assert_eq!(layout.root(), workspace.as_path()); + } + + #[test] + fn resolve_ignores_child_project_records_without_memory_marker() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let child = workspace.join(".worktree/child"); + std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap(); + std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap(); + std::fs::create_dir_all(child.join(".yoi/workflow")).unwrap(); + + let cfg = manifest::MemoryConfig::default(); + let layout = WorkspaceLayout::resolve(&cfg, &child); + assert_eq!(layout.root(), workspace.as_path()); + } + + #[test] + fn yoi_project_records_alone_do_not_define_memory_marker_root() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let child = workspace.join("child"); + std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap(); + std::fs::create_dir_all(workspace.join(".yoi/workflow")).unwrap(); + std::fs::create_dir_all(&child).unwrap(); + + assert_eq!(find_memory_marker_root(&child), None); + + let cfg = manifest::MemoryConfig::default(); + let layout = WorkspaceLayout::resolve(&cfg, &child); + assert_eq!(layout.root(), child.as_path()); } }