From 4beb1f61eacb980f372d2060320878ab87a77d1a Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 1 Jun 2026 22:06:41 +0900 Subject: [PATCH 1/2] feat: add workspace local manifest override --- crates/manifest/src/lib.rs | 3 +- crates/manifest/src/profile.rs | 166 +++++++++++++++++- crates/pod/src/entrypoint.rs | 45 +++++ docs/design/profiles-manifests-prompts.md | 2 + .../artifacts/implementation-report.md | 22 +++ .../item.md | 2 +- .../thread.md | 30 ++++ 7 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 244e1513..5df70cc5 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -17,7 +17,8 @@ pub use paths::user_profiles_path; pub use profile::{ ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, - ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, + ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot, + resolve_profile_artifact, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{Scope, ScopeError, SharedScope}; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 046ad6be..272ea02c 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -25,6 +25,7 @@ const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua"); const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); const DEFAULT_POD_NAME: &str = "yoi"; +const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml"; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -340,6 +341,19 @@ pub struct ProfileManifestSnapshot { pub source: ProfileSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_override: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceOverrideSnapshot { + pub path: PathBuf, +} + +#[derive(Debug)] +struct WorkspaceOverrideLayer { + path: PathBuf, + config: PodManifestConfig, } #[derive(Debug, Clone)] @@ -475,6 +489,7 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; + let workspace_override = load_workspace_override_from(&workspace_base)?; let lua_value = evaluate_lua_profile(&absolute_path, &profile_dir)?; let raw_artifact = lua_value.clone(); resolve_lua_profile_value( @@ -484,6 +499,7 @@ impl ProfileResolver { options, lua_value, raw_artifact, + workspace_override, ) } @@ -499,6 +515,7 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; + let workspace_override = load_workspace_override_from(&workspace_base)?; let lua_value = evaluate_embedded_lua_profile(label, content)?; let raw_artifact = lua_value.clone(); resolve_lua_profile_value( @@ -508,6 +525,7 @@ impl ProfileResolver { options, lua_value, raw_artifact, + workspace_override, ) } } @@ -519,6 +537,7 @@ fn resolve_lua_profile_value( options: ProfileResolveOptions, value: serde_json::Value, raw_artifact: serde_json::Value, + workspace_override: Option, ) -> Result { if !workspace_base.is_absolute() { return Err(ProfileError::InvalidPath { @@ -554,11 +573,28 @@ fn resolve_lua_profile_value( memory: profile.memory, skills: profile.skills, }; - let config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); + let mut config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); + let workspace_override_snapshot = if let Some(override_layer) = workspace_override { + let override_base = + override_layer + .path + .parent() + .ok_or_else(|| ProfileError::InvalidPath { + path: override_layer.path.clone(), + message: "workspace override path has no parent directory".into(), + })?; + config = config.merge(override_layer.config.resolve_paths(override_base)); + Some(WorkspaceOverrideSnapshot { + path: override_layer.path, + }) + } else { + None + }; let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; manifest.profile = Some(ProfileManifestSnapshot { source: source.clone(), profile: profile_meta.clone(), + workspace_override: workspace_override_snapshot, }); let manifest_snapshot = serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?; @@ -687,6 +723,54 @@ fn load_profile_registry_file( Ok(()) } +fn load_workspace_override_from( + workspace_base: &Path, +) -> Result, ProfileError> { + find_workspace_override_from(workspace_base) + .map(|path| load_workspace_override_file(&path)) + .transpose() +} + +fn load_workspace_override_file(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|source| ProfileError::WorkspaceOverrideRead { + path: path.to_path_buf(), + source, + })?; + let config = PodManifestConfig::from_toml(&content).map_err(|source| { + ProfileError::WorkspaceOverrideParse { + path: path.to_path_buf(), + source, + } + })?; + if config.pod.name.is_some() { + return Err(ProfileError::InvalidWorkspaceOverride { + path: path.to_path_buf(), + message: "workspace-local manifest overrides cannot set pod.name; Pod identity is a runtime input".into(), + }); + } + Ok(WorkspaceOverrideLayer { + path: path.to_path_buf(), + config, + }) +} + +fn find_workspace_override_from(start: &Path) -> Option { + let start = start + .canonicalize() + .ok() + .unwrap_or_else(|| start.to_path_buf()); + let mut cur: Option<&Path> = Some(start.as_path()); + while let Some(dir) = cur { + let candidate = dir.join(".yoi").join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); + if candidate.is_file() { + return Some(candidate); + } + cur = dir.parent(); + } + None +} + fn find_project_profiles_from(start: &Path) -> Option { let start = start .canonicalize() @@ -1214,6 +1298,7 @@ pub fn resolve_profile_artifact( ProfileResolveOptions::default(), raw_artifact.clone(), raw_artifact, + None, ) } @@ -1241,6 +1326,20 @@ pub enum ProfileError { #[source] source: toml::de::Error, }, + #[error("failed to read workspace local manifest override {}: {source}", .path.display())] + WorkspaceOverrideRead { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse workspace local manifest override {}: {source}", .path.display())] + WorkspaceOverrideParse { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + #[error("invalid workspace local manifest override {}: {message}", .path.display())] + InvalidWorkspaceOverride { path: PathBuf, message: String }, #[error("no default profile is configured")] NoDefaultProfile, #[error("profile not found: {selector}")] @@ -1498,6 +1597,71 @@ return profile { } ); } + #[test] + fn workspace_local_override_layers_over_profile_defaults() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("project"); + let nested = workspace.join("nested"); + let yoi_dir = workspace.join(".yoi"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(&yoi_dir).unwrap(); + let override_path = yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); + std::fs::write( + &override_path, + r#" +[pod] +prompt_pack = "prompts.toml" +[worker] +language = "ja" +[session] +record_event_trace = false +"#, + ) + .unwrap(); + + let resolved = ProfileResolver::new() + .with_workspace_base(&nested) + .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .unwrap(); + + assert_eq!(resolved.manifest.pod.name, "yoi"); + assert_eq!(resolved.manifest.worker.language, "ja"); + assert!(!resolved.manifest.session.record_event_trace); + assert_eq!( + resolved.manifest.pod.prompt_pack.as_deref(), + Some(yoi_dir.join("prompts.toml").as_path()) + ); + assert_eq!(resolved.manifest.scope.allow[0].target, nested); + assert_eq!( + resolved + .manifest + .profile + .as_ref() + .and_then(|snapshot| snapshot.workspace_override.as_ref()) + .map(|snapshot| snapshot.path.as_path()), + Some(override_path.as_path()) + ); + } + + #[test] + fn workspace_local_override_rejects_runtime_pod_name() { + let tmp = TempDir::new().unwrap(); + let yoi_dir = tmp.path().join(".yoi"); + std::fs::create_dir_all(&yoi_dir).unwrap(); + std::fs::write( + yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), + "[pod]\nname = \"not-local\"\n", + ) + .unwrap(); + + let err = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. })); + assert!(err.to_string().contains("pod.name")); + } + #[test] fn unsupported_profile_extension_has_clear_diagnostic() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pod/src/entrypoint.rs b/crates/pod/src/entrypoint.rs index 1398ffa6..3d2da442 100644 --- a/crates/pod/src/entrypoint.rs +++ b/crates/pod/src/entrypoint.rs @@ -485,6 +485,51 @@ permission = "write" assert!(loader.workspace_dir().is_none()); } + #[test] + fn manifest_mode_does_not_apply_workspace_local_override() { + let tmp = TempDir::new().unwrap(); + let yoi_dir = tmp.path().join(".yoi"); + std::fs::create_dir_all(&yoi_dir).unwrap(); + write( + &yoi_dir.join("override.local.toml"), + r#" +[pod] +name = "from-local-override" +[worker] +language = "override" +"#, + ); + let manifest_path = tmp.path().join("manifest.toml"); + write( + &manifest_path, + &format!( + r#" +[pod] +name = "from-single-file" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[worker] +language = "manifest" + +[[scope.allow]] +target = "{}" +permission = "write" +"#, + tmp.path().display() + ), + ); + + let cli = Cli::try_parse_from(["yoi pod", "--manifest", manifest_path.to_str().unwrap()]) + .unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); + + assert_eq!(manifest.pod.name, "from-single-file"); + assert_eq!(manifest.worker.language, "manifest"); + } + #[test] fn profile_uses_selected_profile() { let tmp = TempDir::new().unwrap(); diff --git a/docs/design/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index 410a9b38..76966842 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -27,6 +27,8 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno `--manifest ` exists as an explicit low-level escape hatch. Normal fresh startup should select a Profile through `profiles.toml` / builtin defaults rather than ambient manifest cascades. +For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest ` mode, and it cannot set `pod.name` because Pod identity remains a runtime input. + ## Spawned Pods `SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope. diff --git a/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md b/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md new file mode 100644 index 00000000..f6b032c9 --- /dev/null +++ b/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md @@ -0,0 +1,22 @@ +# Implementation report + +Implemented workspace-local manifest override support. + +## Behavior + +- Normal Profile/default resolution now searches upward from the resolver workspace base for the nearest `.yoi/override.local.toml`. +- The override is parsed as a `PodManifestConfig` layer, resolved relative to its parent `.yoi/` directory, and merged after builtin defaults plus the selected Profile but before final `PodManifest` validation and snapshot serialization. +- Resolved profile provenance now records the applied override path in `manifest.profile.workspace_override`. +- Explicit `--manifest ` mode remains a single-file escape hatch and does not apply workspace-local overrides. +- Workspace-local overrides are rejected if they set `pod.name`, keeping Pod identity runtime-bound. + +## Validation + +- `cargo test -p manifest workspace_local_override -- --nocapture` +- `cargo test -p pod manifest_mode_does_not_apply_workspace_local_override -- --nocapture` +- `cargo test -p manifest -p pod` +- `./tickets.sh doctor` +- `git diff --check` +- `nix build .#yoi` + +All completed successfully. diff --git a/work-items/open/20260601-125240-workspace-local-manifest-override/item.md b/work-items/open/20260601-125240-workspace-local-manifest-override/item.md index 284abbfc..27e2dc91 100644 --- a/work-items/open/20260601-125240-workspace-local-manifest-override/item.md +++ b/work-items/open/20260601-125240-workspace-local-manifest-override/item.md @@ -7,7 +7,7 @@ kind: task priority: P2 labels: [profile, manifest, config] created_at: 2026-06-01T12:52:40Z -updated_at: 2026-06-01T12:53:58Z +updated_at: 2026-06-01T13:05:58Z assignee: null legacy_ticket: null --- diff --git a/work-items/open/20260601-125240-workspace-local-manifest-override/thread.md b/work-items/open/20260601-125240-workspace-local-manifest-override/thread.md index ba8805b7..cde761db 100644 --- a/work-items/open/20260601-125240-workspace-local-manifest-override/thread.md +++ b/work-items/open/20260601-125240-workspace-local-manifest-override/thread.md @@ -50,4 +50,34 @@ Validation: - Record any skipped validation with rationale. +--- + + + +## Implementation report + +# Implementation report + +Implemented workspace-local manifest override support. + +## Behavior + +- Normal Profile/default resolution now searches upward from the resolver workspace base for the nearest `.yoi/override.local.toml`. +- The override is parsed as a `PodManifestConfig` layer, resolved relative to its parent `.yoi/` directory, and merged after builtin defaults plus the selected Profile but before final `PodManifest` validation and snapshot serialization. +- Resolved profile provenance now records the applied override path in `manifest.profile.workspace_override`. +- Explicit `--manifest ` mode remains a single-file escape hatch and does not apply workspace-local overrides. +- Workspace-local overrides are rejected if they set `pod.name`, keeping Pod identity runtime-bound. + +## Validation + +- `cargo test -p manifest workspace_local_override -- --nocapture` +- `cargo test -p pod manifest_mode_does_not_apply_workspace_local_override -- --nocapture` +- `cargo test -p manifest -p pod` +- `./tickets.sh doctor` +- `git diff --check` +- `nix build .#yoi` + +All completed successfully. + + --- From 8f987857e8efb22af5c8606163099f0070c15b58 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 1 Jun 2026 22:16:06 +0900 Subject: [PATCH 2/2] test: cover nearest workspace override --- crates/manifest/src/profile.rs | 54 +++++++++++++++++++ .../artifacts/implementation-report.md | 6 +++ 2 files changed, 60 insertions(+) diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 272ea02c..00f30b19 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1643,6 +1643,60 @@ record_event_trace = false ); } + #[test] + fn workspace_local_override_uses_nearest_ancestor() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("project"); + let nested = workspace.join("nested"); + let child = nested.join("child"); + let parent_yoi = workspace.join(".yoi"); + let nested_yoi = nested.join(".yoi"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::create_dir_all(&parent_yoi).unwrap(); + std::fs::create_dir_all(&nested_yoi).unwrap(); + std::fs::write( + parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), + r#" +[pod] +prompt_pack = "parent-prompts.toml" +[worker] +language = "parent" +"#, + ) + .unwrap(); + let nested_override_path = nested_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME); + std::fs::write( + &nested_override_path, + r#" +[pod] +prompt_pack = "nested-prompts.toml" +[worker] +language = "nested" +"#, + ) + .unwrap(); + + let resolved = ProfileResolver::new() + .with_workspace_base(&child) + .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .unwrap(); + + assert_eq!(resolved.manifest.worker.language, "nested"); + assert_eq!( + resolved.manifest.pod.prompt_pack.as_deref(), + Some(nested_yoi.join("nested-prompts.toml").as_path()) + ); + assert_eq!( + resolved + .manifest + .profile + .as_ref() + .and_then(|snapshot| snapshot.workspace_override.as_ref()) + .map(|snapshot| snapshot.path.as_path()), + Some(nested_override_path.as_path()) + ); + } + #[test] fn workspace_local_override_rejects_runtime_pod_name() { let tmp = TempDir::new().unwrap(); diff --git a/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md b/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md index f6b032c9..3bb9d571 100644 --- a/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md +++ b/work-items/open/20260601-125240-workspace-local-manifest-override/artifacts/implementation-report.md @@ -19,4 +19,10 @@ Implemented workspace-local manifest override support. - `git diff --check` - `nix build .#yoi` +Reviewer follow-up validation: + +- `cargo test -p manifest workspace_local_override -- --nocapture` +- `./tickets.sh doctor` +- `git diff --check` + All completed successfully.