From 85c06dc62ec582d16a503a5bbeb6cd375111f286 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 11 Jun 2026 00:11:39 +0900 Subject: [PATCH] feat: add builtin role profiles --- .yoi/profiles.toml | 22 +- .yoi/profiles/_base.lua | 55 ---- .yoi/ticket.config.toml | 8 +- crates/client/src/ticket_role.rs | 7 +- crates/manifest/src/profile.rs | 287 ++++++++++-------- crates/ticket/src/config.rs | 24 +- {.yoi => resources}/profiles/coder.lua | 13 +- {.yoi => resources}/profiles/companion.lua | 11 +- {.yoi => resources}/profiles/intake.lua | 11 +- {.yoi => resources}/profiles/orchestrator.lua | 15 +- {.yoi => resources}/profiles/reviewer.lua | 11 +- 11 files changed, 218 insertions(+), 246 deletions(-) delete mode 100644 .yoi/profiles/_base.lua rename {.yoi => resources}/profiles/coder.lua (53%) rename {.yoi => resources}/profiles/companion.lua (61%) rename {.yoi => resources}/profiles/intake.lua (61%) rename {.yoi => resources}/profiles/orchestrator.lua (52%) rename {.yoi => resources}/profiles/reviewer.lua (61%) diff --git a/.yoi/profiles.toml b/.yoi/profiles.toml index 89c30499..7fde639f 100644 --- a/.yoi/profiles.toml +++ b/.yoi/profiles.toml @@ -1,21 +1 @@ -default = "project:companion" - -[profile.companion] -description = "Companion role profile: GPT-5.5 with bundled default behavior" -path = "profiles/companion.lua" - -[profile.intake] -description = "Intake role profile: GPT-5.5 with bundled default behavior" -path = "profiles/intake.lua" - -[profile.orchestrator] -description = "Orchestrator role profile: GPT-5.5 with bundled default behavior" -path = "profiles/orchestrator.lua" - -[profile.coder] -description = "Coder role profile: GPT-5.5 with bundled default behavior" -path = "profiles/coder.lua" - -[profile.reviewer] -description = "Reviewer role profile: GPT-5.5 with bundled default behavior" -path = "profiles/reviewer.lua" +default = "builtin:companion" diff --git a/.yoi/profiles/_base.lua b/.yoi/profiles/_base.lua deleted file mode 100644 index e7cc28c7..00000000 --- a/.yoi/profiles/_base.lua +++ /dev/null @@ -1,55 +0,0 @@ -local profile = require("yoi.profile") -local scope = require("yoi.scope") -local compact = require("yoi.compact") - -return function(opts) - return profile { - slug = opts.slug, - description = opts.description, - - scope = opts.scope or scope.workspace_read(), - delegation_scope = opts.delegation_scope, - - session = { - record_event_trace = true, - }, - - worker = { - reasoning = "high", - language = opts.language or "Japanese", - }, - - model = { - ref = opts.model_ref, - }, - - compaction = compact.tokens { - threshold = 240000, - request_threshold = 270000, - worker_context_max_tokens = 100000, - }, - - feature = opts.feature or { - task = { enabled = true }, - memory = { enabled = true }, - web = { enabled = true }, - pods = { enabled = false }, - ticket = { enabled = false, access = "lifecycle" }, - ticket_orchestration = { enabled = false }, - }, - - memory = { - extract_threshold = 50000, - consolidation_threshold_files = 5, - consolidation_threshold_bytes = 50000, - }, - - web = { - enabled = true, - search = { - provider = "brave", - api_key_secret = "web/brave/default", - }, - }, - } -end diff --git a/.yoi/ticket.config.toml b/.yoi/ticket.config.toml index 3c8278f1..4b1da71e 100644 --- a/.yoi/ticket.config.toml +++ b/.yoi/ticket.config.toml @@ -6,17 +6,17 @@ root = ".yoi/tickets" language = "Japanese" [roles.intake] -profile = "project:intake" +profile = "builtin:intake" workflow = "ticket-intake-workflow" [roles.orchestrator] -profile = "project:orchestrator" +profile = "builtin:orchestrator" workflow = "ticket-orchestrator-routing" [roles.coder] -profile = "project:coder" +profile = "builtin:coder" workflow = "multi-agent-workflow" [roles.reviewer] -profile = "project:reviewer" +profile = "builtin:reviewer" workflow = "multi-agent-workflow" diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index 0c817e03..77fbcf7a 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -1035,7 +1035,7 @@ profile = "builtin:default" )) .unwrap(); assert_eq!(intake.role, TicketRole::Intake); - assert_eq!(intake.profile, "builtin:default"); + assert_eq!(intake.profile, TicketRole::Intake.default_profile()); assert_eq!(intake.workflow, TicketRole::Intake.default_workflow()); let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new( @@ -1044,7 +1044,10 @@ profile = "builtin:default" )) .unwrap(); assert_eq!(orchestrator.role, TicketRole::Orchestrator); - assert_eq!(orchestrator.profile, "builtin:default"); + assert_eq!( + orchestrator.profile, + TicketRole::Orchestrator.default_profile() + ); assert_eq!( orchestrator.workflow, TicketRole::Orchestrator.default_workflow() diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 08cd0749..ff553006 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -25,9 +25,61 @@ use crate::{ const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1"; const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua"); +const BUILTIN_COMPANION_PROFILE: &str = include_str!("../../../resources/profiles/companion.lua"); +const BUILTIN_INTAKE_PROFILE: &str = include_str!("../../../resources/profiles/intake.lua"); +const BUILTIN_ORCHESTRATOR_PROFILE: &str = + include_str!("../../../resources/profiles/orchestrator.lua"); +const BUILTIN_CODER_PROFILE: &str = include_str!("../../../resources/profiles/coder.lua"); +const BUILTIN_REVIEWER_PROFILE: &str = include_str!("../../../resources/profiles/reviewer.lua"); const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml"; +struct BuiltinProfile { + name: &'static str, + label: &'static str, + content: &'static str, + description: &'static str, +} + +const BUILTIN_PROFILES: &[BuiltinProfile] = &[ + BuiltinProfile { + name: BUILTIN_DEFAULT_PROFILE_NAME, + label: "builtin:default", + content: BUILTIN_DEFAULT_PROFILE, + description: "Bundled default Yoi coding profile", + }, + BuiltinProfile { + name: "companion", + label: "builtin:companion", + content: BUILTIN_COMPANION_PROFILE, + description: "Bundled Companion role profile", + }, + BuiltinProfile { + name: "intake", + label: "builtin:intake", + content: BUILTIN_INTAKE_PROFILE, + description: "Bundled Intake role profile", + }, + BuiltinProfile { + name: "orchestrator", + label: "builtin:orchestrator", + content: BUILTIN_ORCHESTRATOR_PROFILE, + description: "Bundled Orchestrator role profile", + }, + BuiltinProfile { + name: "coder", + label: "builtin:coder", + content: BUILTIN_CODER_PROFILE, + description: "Bundled Coder role profile", + }, + BuiltinProfile { + name: "reviewer", + label: "builtin:reviewer", + content: BUILTIN_REVIEWER_PROFILE, + description: "Bundled Reviewer role profile", + }, +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProfileRegistrySource { @@ -798,13 +850,15 @@ fn find_project_profiles_from(start: &Path) -> Option { } fn add_builtin_profiles(registry: &mut ProfileRegistry) { - registry.push_entry(ProfileRegistryEntry::embedded( - ProfileRegistrySource::Builtin, - BUILTIN_DEFAULT_PROFILE_NAME, - "builtin:default", - BUILTIN_DEFAULT_PROFILE, - Some("Bundled default Yoi coding profile".into()), - )); + for profile in BUILTIN_PROFILES { + registry.push_entry(ProfileRegistryEntry::embedded( + ProfileRegistrySource::Builtin, + profile.name, + profile.label, + profile.content, + Some(profile.description.into()), + )); + } } fn parse_profile_ref(raw: &str) -> (Option, String) { @@ -1010,15 +1064,18 @@ fn profile_module(lua: &Lua) -> mlua::Result { Ok(module) } fn import_profile_artifact(lua: &Lua, reference: &str) -> mlua::Result { - let source = match reference { - "builtin:default" | "default" => BUILTIN_DEFAULT_PROFILE, - other => { - return Err(mlua::Error::RuntimeError(format!( - "unsupported profile import `{other}`" - ))); - } - }; - lua.load(source).set_name(reference).eval::() + let profile = builtin_profile_by_ref(reference).ok_or_else(|| { + mlua::Error::RuntimeError(format!("unsupported profile import `{reference}`")) + })?; + lua.load(profile.content) + .set_name(profile.label) + .eval::() +} +fn builtin_profile_by_ref(reference: &str) -> Option<&'static BuiltinProfile> { + let name = reference.strip_prefix("builtin:").unwrap_or(reference); + BUILTIN_PROFILES + .iter() + .find(|profile| profile.name == name || profile.label == reference) } fn deep_merge_profile_json(base: &mut serde_json::Value, overrides: serde_json::Value) { match (base, overrides) { @@ -1458,6 +1515,98 @@ mod tests { assert_eq!(default.path, None); assert_eq!(default.provenance, "builtin:default"); } + #[test] + fn builtin_role_profiles_are_registered_and_resolve() { + let tmp = TempDir::new().unwrap(); + let registry = ProfileDiscovery::with_sources(None, None) + .discover() + .unwrap(); + for expected in ["companion", "intake", "orchestrator", "coder", "reviewer"] { + let entry = registry + .select(&ProfileSelector::source_named( + ProfileRegistrySource::Builtin, + expected, + )) + .unwrap(); + assert_eq!(entry.source, ProfileRegistrySource::Builtin); + assert_eq!(entry.path, None); + assert_eq!(entry.provenance, format!("builtin:{expected}")); + + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected), + ProfileResolveOptions::with_pod_name("role-pod"), + ) + .unwrap(); + assert_eq!( + resolved.profile.as_ref().unwrap().name.as_deref(), + Some(expected) + ); + assert_eq!(resolved.manifest.pod.name, "role-pod"); + } + } + + #[test] + fn builtin_role_profiles_preserve_role_tool_policy() { + let tmp = TempDir::new().unwrap(); + let resolve = |role: &str| { + ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, role), + ProfileResolveOptions::with_pod_name("role-pod"), + ) + .unwrap() + .manifest + }; + + let companion = resolve("companion"); + assert!(!companion.feature.task.enabled); + assert!(!companion.feature.pods.enabled); + assert!(!companion.feature.ticket.enabled); + assert_eq!(companion.scope.allow[0].permission, Permission::Read); + assert!(companion.model.ref_.is_none()); + assert!(companion.web.is_none()); + + let intake = resolve("intake"); + assert!(!intake.feature.task.enabled); + assert!(!intake.feature.pods.enabled); + assert!(intake.feature.ticket.enabled); + assert_eq!(intake.scope.allow[0].permission, Permission::Read); + assert!(intake.model.ref_.is_none()); + assert!(intake.web.is_none()); + assert!(!intake.feature.ticket_orchestration.enabled); + + let orchestrator = resolve("orchestrator"); + assert!(!orchestrator.feature.task.enabled); + assert!(orchestrator.feature.pods.enabled); + assert!(orchestrator.feature.ticket.enabled); + assert!(orchestrator.feature.ticket_orchestration.enabled); + assert_eq!(orchestrator.scope.allow[0].permission, Permission::Read); + assert!(orchestrator.model.ref_.is_none()); + assert!(orchestrator.web.is_none()); + assert_eq!( + orchestrator.delegation_scope.allow[0].permission, + Permission::Write + ); + + let coder = resolve("coder"); + assert!(!coder.feature.task.enabled); + assert!(!coder.feature.pods.enabled); + assert_eq!(coder.scope.allow[0].permission, Permission::Write); + assert!(coder.model.ref_.is_none()); + assert!(coder.web.is_none()); + + let reviewer = resolve("reviewer"); + assert!(!reviewer.feature.task.enabled); + assert!(!reviewer.feature.pods.enabled); + assert!(!reviewer.feature.ticket.enabled); + assert_eq!(reviewer.scope.allow[0].permission, Permission::Read); + assert!(reviewer.model.ref_.is_none()); + assert!(reviewer.web.is_none()); + } + #[test] fn profile_resolution_requires_runtime_pod_name() { let tmp = TempDir::new().unwrap(); @@ -1932,112 +2081,6 @@ language = "nested" assert!(matches!(err, ProfileError::UnsupportedProfileType { .. })); assert!(err.to_string().contains("Lua profiles must end in .lua")); } - #[test] - fn actual_project_role_profiles_resolve_explicit_feature_defaults() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let profiles_dir = tmp.path().join(".yoi/profiles"); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::create_dir_all(&profiles_dir).unwrap(); - for (name, content) in [ - ( - "_base.lua", - include_str!("../../../.yoi/profiles/_base.lua"), - ), - ( - "coder.lua", - include_str!("../../../.yoi/profiles/coder.lua"), - ), - ( - "intake.lua", - include_str!("../../../.yoi/profiles/intake.lua"), - ), - ( - "orchestrator.lua", - include_str!("../../../.yoi/profiles/orchestrator.lua"), - ), - ( - "reviewer.lua", - include_str!("../../../.yoi/profiles/reviewer.lua"), - ), - ( - "companion.lua", - include_str!("../../../.yoi/profiles/companion.lua"), - ), - ] { - std::fs::write(profiles_dir.join(name), content).unwrap(); - } - - struct Expected { - role: &'static str, - ticket: bool, - ticket_orchestration: bool, - pods: bool, - } - - for expected in [ - Expected { - role: "orchestrator", - ticket: true, - ticket_orchestration: true, - pods: true, - }, - Expected { - role: "coder", - ticket: false, - ticket_orchestration: false, - pods: false, - }, - Expected { - role: "intake", - ticket: true, - ticket_orchestration: false, - pods: false, - }, - Expected { - role: "reviewer", - ticket: false, - ticket_orchestration: false, - pods: false, - }, - Expected { - role: "companion", - ticket: false, - ticket_orchestration: false, - pods: false, - }, - ] { - let resolved = ProfileResolver::new() - .with_workspace_base(&workspace) - .resolve( - &ProfileSelector::path(profiles_dir.join(format!("{}.lua", expected.role))), - ProfileResolveOptions::with_pod_name(format!("{}-pod", expected.role)), - ) - .unwrap(); - let feature = &resolved.manifest.feature; - assert!( - !feature.task.enabled, - "{} profile must explicitly keep Task tools disabled", - expected.role - ); - assert_eq!( - feature.ticket.enabled, expected.ticket, - "{} ticket feature default mismatch", - expected.role - ); - assert_eq!( - feature.ticket_orchestration.enabled, expected.ticket_orchestration, - "{} ticket orchestration feature default mismatch", - expected.role - ); - assert_eq!( - feature.pods.enabled, expected.pods, - "{} Pod feature default mismatch", - expected.role - ); - } - } - #[test] fn discovery_reads_user_and_project_registry_and_project_default_wins() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index ec2b202c..fdb081c6 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -16,8 +16,6 @@ use thiserror::Error; pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml"; /// Workspace-relative default root for the built-in local Ticket backend. pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; -/// Concrete profile selector used by the initial Ticket role scaffold. -pub const TICKET_CONFIG_SCAFFOLD_PROFILE: &str = "builtin:default"; /// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`. /// @@ -40,7 +38,7 @@ pub fn ticket_config_scaffold() -> String { for role in TicketRole::ALL { out.push_str(&format!( "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", - TICKET_CONFIG_SCAFFOLD_PROFILE, + role.default_profile(), role.default_workflow() )); } @@ -234,6 +232,15 @@ impl TicketRole { Self::Coder | Self::Reviewer => "multi-agent-workflow", } } + + pub fn default_profile(self) -> &'static str { + match self { + Self::Intake => "builtin:intake", + Self::Orchestrator => "builtin:orchestrator", + Self::Coder => "builtin:coder", + Self::Reviewer => "builtin:reviewer", + } + } } impl fmt::Display for TicketRole { @@ -340,15 +347,15 @@ impl Default for TicketRoleProfiles { #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum TicketRoleLaunchConfigError { #[error( - "Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with `profile = \"builtin:default\"` or another executable concrete profile selector" + "Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with the role builtin profile or another executable concrete profile selector" )] MissingRoleTable { role: TicketRole }, #[error( - "Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to `builtin:default` or another executable concrete profile selector" + "Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to the role builtin profile or another executable concrete profile selector" )] MissingProfile { role: TicketRole }, #[error( - "Ticket role `{role}` uses `profile = \"inherit\"`; top-level Ticket role launch requires an explicit executable profile selector such as `builtin:default` or a project/user profile" + "Ticket role `{role}` uses `profile = \"inherit\"`; top-level Ticket role launch requires an explicit executable profile selector such as the role builtin profile or a project/user profile" )] InheritProfile { role: TicketRole }, } @@ -752,7 +759,8 @@ workflow = "multi-agent-workflow" for role in TicketRole::ALL { assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!( - "[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"", + "[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", + role.default_profile(), role.default_workflow() ))); } @@ -767,7 +775,7 @@ workflow = "multi-agent-workflow" assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); for role in TicketRole::ALL { let role_config = config.role_launch_config(role).unwrap(); - assert_eq!(role_config.profile.as_str(), "builtin:default"); + assert_eq!(role_config.profile.as_str(), role.default_profile()); assert_eq!(role_config.workflow.as_str(), role.default_workflow()); } } diff --git a/.yoi/profiles/coder.lua b/resources/profiles/coder.lua similarity index 53% rename from .yoi/profiles/coder.lua rename to resources/profiles/coder.lua index bee244f9..3ffe25b4 100644 --- a/.yoi/profiles/coder.lua +++ b/resources/profiles/coder.lua @@ -1,10 +1,9 @@ -local base = require("_base") -local scope = require("yoi.scope") - -return base { +return yoi.profile { slug = "coder", - description = "Coder role profile: GPT-5.5 with bundled default behavior", - model_ref = "codex-oauth/gpt-5.5", + description = "Coder role profile with bundled reusable policy", + + scope = yoi.scope.workspace_write(), + feature = { task = { enabled = false }, memory = { enabled = true }, @@ -13,6 +12,4 @@ return base { ticket = { enabled = false, access = "lifecycle" }, ticket_orchestration = { enabled = false }, }, - language = "Japanese", - scope = scope.workspace_write(), } diff --git a/.yoi/profiles/companion.lua b/resources/profiles/companion.lua similarity index 61% rename from .yoi/profiles/companion.lua rename to resources/profiles/companion.lua index aaa5e43d..3d556ae6 100644 --- a/.yoi/profiles/companion.lua +++ b/resources/profiles/companion.lua @@ -1,9 +1,9 @@ -local base = require("_base") - -return base { +return yoi.profile { slug = "companion", - description = "Companion role profile: GPT-5.5 with bundled default behavior", - model_ref = "codex-oauth/gpt-5.5", + description = "Companion role profile with bundled reusable policy", + + scope = yoi.scope.workspace_read(), + feature = { task = { enabled = false }, memory = { enabled = true }, @@ -12,5 +12,4 @@ return base { ticket = { enabled = false, access = "lifecycle" }, ticket_orchestration = { enabled = false }, }, - language = "Japanese", } diff --git a/.yoi/profiles/intake.lua b/resources/profiles/intake.lua similarity index 61% rename from .yoi/profiles/intake.lua rename to resources/profiles/intake.lua index 91f96d7b..e83adb15 100644 --- a/.yoi/profiles/intake.lua +++ b/resources/profiles/intake.lua @@ -1,9 +1,9 @@ -local base = require("_base") - -return base { +return yoi.profile { slug = "intake", - description = "Intake role profile: GPT-5.5 with bundled default behavior", - model_ref = "codex-oauth/gpt-5.5", + description = "Intake role profile with bundled reusable policy", + + scope = yoi.scope.workspace_read(), + feature = { task = { enabled = false }, memory = { enabled = true }, @@ -12,5 +12,4 @@ return base { ticket = { enabled = true, access = "lifecycle" }, ticket_orchestration = { enabled = false }, }, - language = "Japanese", } diff --git a/.yoi/profiles/orchestrator.lua b/resources/profiles/orchestrator.lua similarity index 52% rename from .yoi/profiles/orchestrator.lua rename to resources/profiles/orchestrator.lua index ac6b6f9e..eda115db 100644 --- a/.yoi/profiles/orchestrator.lua +++ b/resources/profiles/orchestrator.lua @@ -1,10 +1,9 @@ -local base = require("_base") -local scope = require("yoi.scope") - -return base { +return yoi.profile { slug = "orchestrator", - description = "Orchestrator role profile: GPT-5.5 with bundled default behavior", - delegation_scope = scope.workspace_write(), + description = "Orchestrator role profile with bundled reusable policy", + + scope = yoi.scope.workspace_read(), + feature = { task = { enabled = false }, memory = { enabled = true }, @@ -13,6 +12,6 @@ return base { ticket = { enabled = true, access = "lifecycle" }, ticket_orchestration = { enabled = true }, }, - model_ref = "codex-oauth/gpt-5.5", - language = "Japanese", + + delegation_scope = yoi.scope.workspace_write(), } diff --git a/.yoi/profiles/reviewer.lua b/resources/profiles/reviewer.lua similarity index 61% rename from .yoi/profiles/reviewer.lua rename to resources/profiles/reviewer.lua index a185480c..77779d40 100644 --- a/.yoi/profiles/reviewer.lua +++ b/resources/profiles/reviewer.lua @@ -1,9 +1,9 @@ -local base = require("_base") - -return base { +return yoi.profile { slug = "reviewer", - description = "Reviewer role profile: GPT-5.5 with bundled default behavior", - model_ref = "codex-oauth/gpt-5.5", + description = "Reviewer role profile with bundled reusable policy", + + scope = yoi.scope.workspace_read(), + feature = { task = { enabled = false }, memory = { enabled = true }, @@ -12,5 +12,4 @@ return base { ticket = { enabled = false, access = "lifecycle" }, ticket_orchestration = { enabled = false }, }, - language = "Japanese", }