diff --git a/.yoi/ticket.config.toml b/.yoi/ticket.config.toml deleted file mode 100644 index 4b1da71e..00000000 --- a/.yoi/ticket.config.toml +++ /dev/null @@ -1,22 +0,0 @@ -[backend] -provider = "builtin:yoi_local" -root = ".yoi/tickets" - -[ticket] -language = "Japanese" - -[roles.intake] -profile = "builtin:intake" -workflow = "ticket-intake-workflow" - -[roles.orchestrator] -profile = "builtin:orchestrator" -workflow = "ticket-orchestrator-routing" - -[roles.coder] -profile = "builtin:coder" -workflow = "multi-agent-workflow" - -[roles.reviewer] -profile = "builtin:reviewer" -workflow = "multi-agent-workflow" diff --git a/.yoi/workspace.toml b/.yoi/workspace.toml index 6cfcc713..c31fd9ae 100644 --- a/.yoi/workspace.toml +++ b/.yoi/workspace.toml @@ -1,3 +1,26 @@ workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6" created_at = "2026-06-23T00:00:00Z" display_name = "yoi" + +[ticket] +language = "Japanese" + +[ticket.backend] +provider = "builtin:yoi_local" +root = ".yoi/tickets" + +[ticket.roles.intake] +profile = "builtin:intake" +workflow = "ticket-intake-workflow" + +[ticket.roles.orchestrator] +profile = "builtin:orchestrator" +workflow = "ticket-orchestrator-routing" + +[ticket.roles.coder] +profile = "builtin:coder" +workflow = "multi-agent-workflow" + +[ticket.roles.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 2ce2226d..24214825 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -253,7 +253,7 @@ pub enum TicketRoleLaunchError { #[error(transparent)] LaunchConfig(#[from] TicketRoleLaunchConfigError), #[error( - "Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile" + "Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[ticket.roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile" )] ProfileResolution { role: TicketRole, @@ -287,7 +287,7 @@ pub enum TicketRoleLaunchError { RunAcceptanceTimeout, } -/// Load `.yoi/ticket.config.toml` from the workspace and construct a launch plan. +/// Load Ticket policy from `.yoi/workspace.toml` and construct a launch plan. pub fn plan_ticket_role_launch( context: TicketRoleLaunchContext, ) -> Result { @@ -700,14 +700,14 @@ mod tests { fn write_config(workspace: &std::path::Path, content: &str) { let dir = workspace.join(".yoi"); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ticket.config.toml"), content).unwrap(); + std::fs::write(dir.join("workspace.toml"), content).unwrap(); } fn write_builtin_role_config(workspace: &std::path::Path, roles: &[TicketRole]) { - let mut config = String::new(); + let mut config = String::from("[ticket]\n"); for role in roles { config.push_str(&format!( - "\n[roles.{role}]\nprofile = \"builtin:default\"\n" + "\n[ticket.roles.{role}]\nprofile = \"builtin:default\"\n" )); } write_config(workspace, &config); @@ -883,7 +883,7 @@ mod tests { err.to_string() .contains("Ticket role `coder` is not launch-configured") ); - assert!(err.to_string().contains("[roles.coder]")); + assert!(err.to_string().contains("[ticket.roles.coder]")); } #[test] @@ -913,7 +913,7 @@ root = ".yoi/tickets" write_config( temp.path(), r#" -[roles.intake] +[ticket.roles.intake] profile = "inherit" "#, ); @@ -931,7 +931,7 @@ profile = "inherit" write_config( temp.path(), r#" -[roles.intake] +[ticket.roles.intake] profile = "project:no-such-ticket-role-profile" "#, ); @@ -944,7 +944,7 @@ profile = "project:no-such-ticket-role-profile" "profile selector `project:no-such-ticket-role-profile` is not resolvable" ) ); - assert!(err.to_string().contains("[roles.intake].profile")); + assert!(err.to_string().contains("[ticket.roles.intake].profile")); } #[test] @@ -968,7 +968,7 @@ profile = "project:no-such-ticket-role-profile" [ticket] language = "Japanese" -[roles.intake] +[ticket.roles.intake] profile = "builtin:default" "#, ); @@ -1035,7 +1035,7 @@ profile = "builtin:default" write_config( temp.path(), r#" -[roles.reviewer] +[ticket.roles.reviewer] profile = "builtin:default" launch_prompt = "$workspace/ticket/reviewer/launch" workflow = "ticket-review-workflow" @@ -1226,7 +1226,7 @@ workflow = "ticket-review-workflow" write_config( temp.path(), r#" -[roles.coder] +[ticket.roles.coder] profile = "./coder.toml" "#, ); @@ -1243,7 +1243,7 @@ profile = "./coder.toml" write_config( temp.path(), r#" -[roles.coder] +[ticket.roles.coder] profile = "inherit" system_instruction = "$workspace/not-supported" "#, diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 85751a98..f7a4e47e 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -1,9 +1,12 @@ //! Workspace-local Ticket orchestration configuration. //! -//! The config file lives at `.yoi/ticket.config.toml` under a workspace root. -//! It intentionally stores lightweight string references for Profile selectors, -//! launch prompts, and workflows so this crate remains independent from `worker` -//! and `manifest` runtime resolution. +//! Durable Ticket policy lives under the `[ticket]` table in tracked +//! `.yoi/workspace.toml` workspace settings. The legacy +//! `.yoi/ticket.config.toml` file is only a read-only migration fallback when +//! workspace settings do not contain any Ticket policy. The config intentionally +//! stores lightweight string references for Profile selectors, launch prompts, +//! and workflows so this crate remains independent from `worker` and `manifest` +//! runtime resolution. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -13,6 +16,8 @@ use std::path::{Component, Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; +pub const WORKSPACE_SETTINGS_RELATIVE_PATH: &str = ".yoi/workspace.toml"; +/// Legacy Ticket config path. This is kept as a narrow read-only migration fallback only. 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"; @@ -20,13 +25,17 @@ const DEFAULT_ORCHESTRATION_BRANCH: &str = "orchestration"; const DEFAULT_ORCHESTRATION_WORKTREE_DIR: &str = ".worktree"; const DEFAULT_ORCHESTRATION_WORKTREE_NAME: &str = "orchestration"; -/// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`. +/// Return the explicit Workspace settings Ticket policy scaffold written by `yoi ticket init`. /// -/// The scaffold intentionally configures every fixed Ticket role with a concrete -/// profile so strict role launch planning can validate the config without runtime +/// The scaffold is a valid `.yoi/workspace.toml` fragment rooted at `[ticket]`. +/// It intentionally configures every fixed Ticket role with a concrete profile +/// so strict role launch planning can validate the config without runtime /// fallback. pub fn ticket_config_scaffold() -> String { - let mut out = String::from("[backend]\n"); + let mut out = String::from( + "[ticket]\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# language = \"Japanese\"\n", + ); + out.push_str("\n[ticket.backend]\n"); out.push_str(&format!( "provider = \"{}\"\n", TicketBackendProvider::BuiltinYoiLocal.as_str() @@ -36,14 +45,11 @@ pub fn ticket_config_scaffold() -> String { DEFAULT_TICKET_BACKEND_RELATIVE_PATH )); out.push_str( - "\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n", - ); - out.push_str( - "\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n", + "\n# Optional Panel Orchestrator worktree settings. When unset, Panel uses branch `orchestration` at `.worktree/orchestration`.\n# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"\n", ); for role in TicketRole::ALL { out.push_str(&format!( - "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", + "\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", role.default_profile(), role.default_workflow() )); @@ -222,15 +228,81 @@ impl TicketConfig { pub fn load_workspace(workspace_root: impl AsRef) -> Result { let workspace_root = workspace_root.as_ref(); - let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); - let content = match fs::read_to_string(&path) { + let workspace_settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH); + match fs::read_to_string(&workspace_settings_path) { + Ok(content) => { + if let Some(config) = Self::from_workspace_settings_toml( + workspace_root, + &workspace_settings_path, + &content, + )? { + return Ok(config); + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(TicketConfigError::Read { + path: workspace_settings_path, + source, + }); + } + } + + // Narrow read-only migration fallback for pre-workspace-settings projects. + // As soon as `.yoi/workspace.toml` contains any `[ticket]` table, the + // workspace settings authority wins and this legacy file is ignored. + let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); + let legacy_content = match fs::read_to_string(&legacy_path) { Ok(content) => content, Err(source) if source.kind() == std::io::ErrorKind::NotFound => { return Ok(Self::default_for_workspace(workspace_root)); } - Err(source) => return Err(TicketConfigError::Read { path, source }), + Err(source) => { + return Err(TicketConfigError::Read { + path: legacy_path, + source, + }); + } }; - Self::from_toml(workspace_root, &path, &content) + Self::from_toml(workspace_root, &legacy_path, &legacy_content) + } + + pub fn from_workspace_settings_toml( + workspace_root: impl AsRef, + path: impl AsRef, + content: &str, + ) -> Result, TicketConfigError> { + let workspace_root = workspace_root.as_ref(); + let path = path.as_ref(); + let value: toml::Value = + toml::from_str(content).map_err(|source| TicketConfigError::Parse { + path: path.to_path_buf(), + source, + })?; + let Some(ticket_value) = value.get("ticket").cloned() else { + return Ok(None); + }; + let raw: RawWorkspaceTicketConfig = + ticket_value + .try_into() + .map_err(|source| TicketConfigError::Parse { + path: path.to_path_buf(), + source, + })?; + raw.resolve(workspace_root, path).map(Some) + } + + pub fn workspace_settings_has_ticket_config( + path: impl AsRef, + content: &str, + ) -> Result { + let path = path.as_ref(); + let value: toml::Value = + toml::from_str(content).map_err(|source| TicketConfigError::Parse { + path: path.to_path_buf(), + source, + })?; + Ok(value.get("ticket").is_some()) } pub fn from_toml( @@ -487,11 +559,11 @@ 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 the role builtin profile or another executable concrete profile selector" + "Ticket role `{role}` is not launch-configured; add `[ticket.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 the role builtin profile or another executable concrete profile selector" + "Ticket role `{role}` has no launch profile; set `[ticket.roles.{role}].profile` to the role builtin profile or another executable concrete profile selector" )] MissingProfile { role: TicketRole }, #[error( @@ -675,6 +747,19 @@ struct RawTicketConfig { roles: BTreeMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawWorkspaceTicketConfig { + #[serde(default)] + backend: RawBackendConfig, + #[serde(default)] + language: Option, + #[serde(default)] + orchestration: RawTicketOrchestrationConfig, + #[serde(default)] + roles: BTreeMap, +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawTicketOrchestrationConfig { @@ -723,41 +808,78 @@ impl RawTicketConfig { workspace_root: &Path, path: &Path, ) -> Result { - let mut roles = TicketRoleProfiles::default(); - for (name, raw_role) in self.roles { - let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid { - path: path.to_path_buf(), - message: format!( - "unsupported Ticket role `{name}`; supported fixed roles: {}", - TicketRole::supported_names().join(", ") - ), - })?; - let profile_configured = raw_role.profile.is_some(); - roles.inner.insert(role, raw_role.resolve(role)); - roles.configured_roles.insert(role); - if profile_configured { - roles.profile_configured_roles.insert(role); - } - } - Ok(TicketConfig { - backend: self.backend.resolve(workspace_root).map_err(|message| { - TicketConfigError::Invalid { - path: path.to_path_buf(), - message, - } - })?, - ticket: self.ticket.resolve(), - orchestration: self.orchestration.resolve().map_err(|message| { - TicketConfigError::Invalid { - path: path.to_path_buf(), - message, - } - })?, - roles, - }) + resolve_ticket_config_parts( + self.backend, + self.ticket.resolve(), + self.orchestration, + self.roles, + workspace_root, + path, + ) } } +impl RawWorkspaceTicketConfig { + fn resolve( + self, + workspace_root: &Path, + path: &Path, + ) -> Result { + resolve_ticket_config_parts( + self.backend, + TicketRecordConfig { + language: self.language, + }, + self.orchestration, + self.roles, + workspace_root, + path, + ) + } +} + +fn resolve_ticket_config_parts( + backend: RawBackendConfig, + ticket: TicketRecordConfig, + orchestration: RawTicketOrchestrationConfig, + raw_roles: BTreeMap, + workspace_root: &Path, + path: &Path, +) -> Result { + let mut roles = TicketRoleProfiles::default(); + for (name, raw_role) in raw_roles { + let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid { + path: path.to_path_buf(), + message: format!( + "unsupported Ticket role `{name}`; supported fixed roles: {}", + TicketRole::supported_names().join(", ") + ), + })?; + let profile_configured = raw_role.profile.is_some(); + roles.inner.insert(role, raw_role.resolve(role)); + roles.configured_roles.insert(role); + if profile_configured { + roles.profile_configured_roles.insert(role); + } + } + Ok(TicketConfig { + backend: backend + .resolve(workspace_root) + .map_err(|message| TicketConfigError::Invalid { + path: path.to_path_buf(), + message, + })?, + ticket, + orchestration: orchestration + .resolve() + .map_err(|message| TicketConfigError::Invalid { + path: path.to_path_buf(), + message, + })?, + roles, + }) +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawBackendConfig { @@ -843,6 +965,12 @@ mod tests { std::fs::write(dir.join("ticket.config.toml"), content).unwrap(); } + fn write_workspace_settings(workspace: &Path, content: &str) { + let dir = workspace.join(".yoi"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("workspace.toml"), content).unwrap(); + } + #[test] fn missing_config_returns_documented_defaults() { let temp = TempDir::new().unwrap(); @@ -876,39 +1004,123 @@ mod tests { } #[test] - fn full_config_parses_fixed_role_refs() { + fn workspace_settings_take_precedence_over_legacy_ticket_config() { let temp = TempDir::new().unwrap(); + write_workspace_settings( + temp.path(), + r#" +[ticket] +language = "Japanese" + +[ticket.backend] +provider = "builtin:yoi_local" +root = "workspace-tickets" +"#, + ); write_config( temp.path(), r#" [backend] provider = "builtin:yoi_local" -root = "custom-tickets" +root = "legacy-tickets" + +[ticket] +language = "English" +"#, + ); + + let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!(config.backend.root, temp.path().join("workspace-tickets")); + assert_eq!(config.ticket_record_language(), Some("Japanese")); + } + + #[test] + fn legacy_ticket_config_is_read_only_migration_fallback() { + let temp = TempDir::new().unwrap(); + write_workspace_settings( + temp.path(), + r#" +workspace_id = "00000000-0000-7000-8000-000000000000" +display_name = "legacy-fallback" +"#, + ); + write_config( + temp.path(), + r#" +[backend] +provider = "builtin:yoi_local" +root = "legacy-tickets" [ticket] language = "Japanese" +"#, + ); -[orchestration] + let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!(config.backend.root, temp.path().join("legacy-tickets")); + assert_eq!(config.ticket_record_language(), Some("Japanese")); + } + + #[test] + fn empty_workspace_ticket_table_uses_workspace_defaults_and_ignores_legacy() { + let temp = TempDir::new().unwrap(); + write_workspace_settings( + temp.path(), + r#" +[ticket] +"#, + ); + write_config( + temp.path(), + r#" +[backend] +provider = "builtin:yoi_local" +root = "legacy-tickets" + +[ticket] +language = "Japanese" +"#, + ); + + let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!(config.backend.root, temp.path().join(".yoi/tickets")); + assert_eq!(config.ticket_record_language(), None); + } + + #[test] + fn full_config_parses_fixed_role_refs() { + let temp = TempDir::new().unwrap(); + write_workspace_settings( + temp.path(), + r#" +[ticket] +language = "Japanese" + +[ticket.backend] +provider = "builtin:yoi_local" +root = "custom-tickets" + +[ticket.orchestration] branch = "orchestration/custom-panel" worktree_dir = "custom-worktrees" worktree_name = "custom-orchestrator" -[roles.intake] +[ticket.roles.intake] profile = "project:intake" launch_prompt = "$workspace/ticket/intake/launch" workflow = "ticket-intake-workflow" -[roles.orchestrator] +[ticket.roles.orchestrator] profile = "project:orchestrator" launch_prompt = "$workspace/ticket/orchestrator/launch" workflow = "ticket-orchestrator-routing" -[roles.coder] +[ticket.roles.coder] profile = "inherit" launch_prompt = "$workspace/ticket/coder/launch" workflow = "multi-agent-workflow" -[roles.reviewer] +[ticket.roles.reviewer] profile = "project:reviewer" launch_prompt = "$workspace/ticket/reviewer/launch" workflow = "multi-agent-workflow" @@ -960,28 +1172,30 @@ workflow = "multi-agent-workflow" let temp = TempDir::new().unwrap(); let scaffold = ticket_config_scaffold(); - assert!(scaffold.contains("[backend]\n")); + assert!(scaffold.contains("[ticket]\n")); + assert!(scaffold.contains("[ticket.backend]\n")); assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); assert!(scaffold.contains("root = \".yoi/tickets\"")); - assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); + assert!(scaffold.contains("# language = \"Japanese\"")); assert!(scaffold.contains( - "# [orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"" + "# [ticket.orchestration]\n# branch = \"orchestration\"\n# worktree_dir = \".worktree\"\n# worktree_name = \"orchestration\"" )); for role in TicketRole::ALL { - assert!(scaffold.contains(&format!("[roles.{role}]"))); + assert!(scaffold.contains(&format!("[ticket.roles.{role}]"))); assert!(scaffold.contains(&format!( - "[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", + "[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", role.default_profile(), role.default_workflow() ))); } - assert!(!scaffold.contains("[roles.investigator]")); + assert!(!scaffold.contains("[ticket.roles.investigator]")); - let config = TicketConfig::from_toml( + let config = TicketConfig::from_workspace_settings_toml( temp.path(), - temp.path().join(TICKET_CONFIG_RELATIVE_PATH), + temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH), &scaffold, ) + .unwrap() .unwrap(); assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); assert_eq!(config.orchestration.branch_name(), None); diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index 20597cb3..74070088 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -94,13 +94,13 @@ fn ensure_and_restore_use_configured_orchestration_layout() { write_test_ticket_config( &root, r#" -[orchestration] +[ticket.orchestration] branch = "orchestration/custom-panel" worktree_dir = "custom-worktrees" worktree_name = "panel" "#, ); - run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap(); run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); let resolved = resolved_orchestration_worktree_layout(&root).unwrap(); @@ -126,7 +126,7 @@ fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_opera write_test_ticket_config( &root, r#" -[orchestration] +[ticket.orchestration] branch = "orchestration/bad:branch" "#, ); @@ -145,11 +145,11 @@ fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() write_test_ticket_config( &root, r#" -[orchestration] +[ticket.orchestration] branch = "orchestration/custom-panel" "#, ); - run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap(); run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); let layout = resolved_orchestration_worktree_layout(&root).unwrap(); run_test_git( @@ -259,7 +259,7 @@ fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() { fn write_test_ticket_config(root: &Path, content: &str) { let config_dir = root.join(".yoi"); std::fs::create_dir_all(&config_dir).unwrap(); - std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap(); + std::fs::write(config_dir.join("workspace.toml"), content).unwrap(); } fn init_test_repo(root: &Path) { @@ -337,8 +337,8 @@ fn ticket_workspace( ) .unwrap(); fs::write( - temp.path().join(".yoi/ticket.config.toml"), - "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + temp.path().join(".yoi/workspace.toml"), + "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", ) .unwrap(); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); @@ -712,7 +712,7 @@ async fn ticket_close_action_blocks_non_done_ticket_without_mutation() { #[tokio::test] async fn ticket_action_rejects_stale_absent_config_without_mutation() { let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config"); - fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap(); + fs::remove_file(temp.path().join(".yoi/workspace.toml")).unwrap(); let error = dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) @@ -1059,8 +1059,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() { let temp = TempDir::new().unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::write( - temp.path().join(".yoi/ticket.config.toml"), - "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + temp.path().join(".yoi/workspace.toml"), + "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", ) .unwrap(); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 382818f1..b14fe44a 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -7,7 +7,7 @@ use std::time::Instant; use protocol::WorkerStatus; use ticket::config::{ DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, - TicketOrchestrationConfig, + TicketOrchestrationConfig, WORKSPACE_SETTINGS_RELATIVE_PATH, }; use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, @@ -567,8 +567,41 @@ pub(crate) fn decide_orchestrator_lifecycle( } pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability { - let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); - match config_path.symlink_metadata() { + let settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH); + match settings_path.symlink_metadata() { + Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!( + "{} exists but is not a regular file", + WORKSPACE_SETTINGS_RELATIVE_PATH + )), + Ok(_) => match std::fs::read_to_string(&settings_path) { + Ok(content) => { + match TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content) { + Ok(true) => match TicketConfig::load_workspace(workspace_root) { + Ok(_) => TicketConfigAvailability::Usable, + Err(error) => TicketConfigAvailability::Unusable(error.to_string()), + }, + Ok(false) => legacy_ticket_config_availability(workspace_root), + Err(error) => TicketConfigAvailability::Unusable(error.to_string()), + } + } + Err(error) => TicketConfigAvailability::Unusable(format!( + "could not read {}: {error}", + WORKSPACE_SETTINGS_RELATIVE_PATH + )), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + legacy_ticket_config_availability(workspace_root) + } + Err(error) => TicketConfigAvailability::Unusable(format!( + "could not inspect {}: {error}", + WORKSPACE_SETTINGS_RELATIVE_PATH + )), + } +} + +fn legacy_ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability { + let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); + match legacy_path.symlink_metadata() { Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!( "{} exists but is not a regular file", TICKET_CONFIG_RELATIVE_PATH @@ -1766,8 +1799,8 @@ mod tests { let config_dir = workspace_root.join(".yoi"); fs::create_dir_all(&config_dir).unwrap(); fs::write( - config_dir.join("ticket.config.toml"), - "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + config_dir.join("workspace.toml"), + "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", ) .unwrap(); } @@ -2233,8 +2266,8 @@ mod tests { let config_dir = temp.path().join(".yoi"); fs::create_dir_all(&config_dir).unwrap(); fs::write( - config_dir.join("ticket.config.toml"), - "[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n", + config_dir.join("workspace.toml"), + "[ticket]\n\n[ticket.backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n", ) .unwrap(); @@ -2678,11 +2711,11 @@ mod tests { } #[test] - fn existing_non_file_ticket_config_is_unusable_not_absent() { + fn existing_non_file_workspace_settings_is_unusable_not_absent() { let temp = TempDir::new().unwrap(); let config_parent = temp.path().join(".yoi"); fs::create_dir_all(&config_parent).unwrap(); - fs::create_dir(config_parent.join("ticket.config.toml")).unwrap(); + fs::create_dir(config_parent.join("workspace.toml")).unwrap(); assert!(matches!( ticket_config_availability(temp.path()), diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 5452c00e..54773c4e 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -736,8 +736,8 @@ where "ticket tools require local Worker filesystem authority", ) })?; - crate::feature::builtin::ticket::TicketFeatureBackend::Local { - root: ticket_cwd.clone(), + crate::feature::builtin::ticket::TicketFeatureBackend::LocalWorkspace { + workspace_root: ticket_cwd.clone(), } } }; diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index a6aab4fc..28ec53e7 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -61,6 +61,9 @@ pub enum TicketFeatureBackend { Local { root: PathBuf, }, + LocalWorkspace { + workspace_root: PathBuf, + }, WorkspaceHttp { workspace_id: String, base_url: String, @@ -125,6 +128,13 @@ impl TicketFeature { access: Option, include_orchestration_tools: bool, ) -> Self { + if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend { + return Self::for_workspace_with_options( + workspace_root, + access, + include_orchestration_tools, + ); + } Self { backend, record_language: None, @@ -180,6 +190,7 @@ impl TicketFeature { pub fn backend_root(&self) -> Option<&Path> { match &self.backend { TicketFeatureBackend::Local { root } => Some(root), + TicketFeatureBackend::LocalWorkspace { workspace_root } => Some(workspace_root), TicketFeatureBackend::WorkspaceHttp { .. } => None, } } @@ -219,7 +230,8 @@ impl TicketFeature { } fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option { match &self.backend { - TicketFeatureBackend::Local { root: _ } => { + TicketFeatureBackend::Local { root: _ } + | TicketFeatureBackend::LocalWorkspace { workspace_root: _ } => { let usable_root = match self.usable_backend_root() { Ok(root) => root, Err(reason) => { @@ -606,7 +618,7 @@ mod tests { fn write_ticket_config(workspace: &Path, content: &str) { let yoi_dir = workspace.join(".yoi"); std::fs::create_dir_all(&yoi_dir).unwrap(); - std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap(); + std::fs::write(yoi_dir.join("workspace.toml"), content).unwrap(); } fn pending_tool_description( @@ -853,11 +865,11 @@ language = "Japanese" write_ticket_config( temp.path(), r#" -[backend] +[ticket.backend] provider = "builtin:yoi_local" root = "tickets" -[roles.coder] +[ticket.roles.coder] profile = "project:coder" "#, ); @@ -886,7 +898,7 @@ profile = "project:coder" write_ticket_config( temp.path(), r#" -[roles.operator] +[ticket.roles.operator] profile = "inherit" "#, ); @@ -911,7 +923,7 @@ profile = "inherit" write_ticket_config( temp.path(), r#" -[backend] +[ticket.backend] provider = "github" "#, ); diff --git a/crates/workspace-server/src/identity.rs b/crates/workspace-server/src/identity.rs index d2209646..3ff7e555 100644 --- a/crates/workspace-server/src/identity.rs +++ b/crates/workspace-server/src/identity.rs @@ -12,10 +12,10 @@ pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml"; /// Stable local Workspace identity persisted as a tracked, safe project record. /// -/// The v0 TOML schema intentionally contains identity metadata only: -/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected -/// instead of preserved because this loader cannot safely round-trip future local -/// runtime settings without risking accidental path or secret persistence. +/// The v0 TOML schema contains identity metadata plus optional tracked project +/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected +/// here because this loader cannot safely round-trip future local runtime settings +/// without risking accidental path or secret persistence. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceIdentity { pub workspace_id: String, @@ -29,6 +29,8 @@ struct WorkspaceIdentityFile { workspace_id: String, created_at: String, display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + ticket: Option, } impl WorkspaceIdentity { @@ -112,6 +114,7 @@ impl WorkspaceIdentity { workspace_id: self.workspace_id.clone(), created_at: self.created_at.clone(), display_name: self.display_name.clone(), + ticket: None, }) .map_err(|error| { workspace_identity_error(path, format!("failed to encode TOML: {error}")) diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 2f32122b..204c8f08 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use project_record::validate_record_id; use serde::{Deserialize, Serialize}; +use ticket::config::TicketConfig; use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use crate::{Error, Result}; @@ -17,13 +18,16 @@ pub struct LocalProjectRecordReader { } impl LocalProjectRecordReader { - pub fn new(workspace_root: impl Into) -> Self { + pub fn new(workspace_root: impl Into) -> Result { let workspace_root = workspace_root.into(); - let ticket_root = workspace_root.join(".yoi/tickets"); - Self { + let ticket_config = TicketConfig::load_workspace(&workspace_root) + .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; + let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf()) + .with_record_language(ticket_config.ticket_record_language()); + Ok(Self { workspace_root, - ticket_backend: LocalTicketBackend::new(ticket_root), - } + ticket_backend, + }) } pub fn workspace_root(&self) -> &Path { @@ -297,7 +301,7 @@ mod tests { write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); write_objective(dir.path(), "00000000001J3", "Control plane", "active"); - let reader = LocalProjectRecordReader::new(dir.path()); + let reader = LocalProjectRecordReader::new(dir.path()).unwrap(); let tickets = reader.list_tickets(20).unwrap(); assert_eq!(tickets.record_authority, "local_yoi_project_records"); assert_eq!(tickets.items[0].id, "00000000001J2"); @@ -313,9 +317,41 @@ mod tests { let objective = reader.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); } + #[test] + fn reads_tickets_from_workspace_settings_backend_root() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write( + dir.path().join(".yoi/workspace.toml"), + r#" +[ticket] +language = "Japanese" +[ticket.backend] +provider = "builtin:yoi_local" +root = "project-records/tickets" +"#, + ) + .unwrap(); + write_ticket_at( + &dir.path().join("project-records/tickets"), + "00000000001J4", + "Configured root", + "ready", + ); + write_ticket(dir.path(), "00000000001J5", "Default root", "ready"); + + let reader = LocalProjectRecordReader::new(dir.path()).unwrap(); + let tickets = reader.list_tickets(20).unwrap(); + assert_eq!(tickets.items.len(), 1); + assert_eq!(tickets.items[0].id, "00000000001J4"); + } fn write_ticket(root: &Path, id: &str, title: &str, state: &str) { - let ticket_dir = root.join(".yoi/tickets").join(id); + write_ticket_at(&root.join(".yoi/tickets"), id, title, state); + } + + fn write_ticket_at(ticket_root: &Path, id: &str, title: &str, state: &str) { + let ticket_dir = ticket_root.join(id); fs::create_dir_all(&ticket_dir).unwrap(); fs::write( ticket_dir.join("item.md"), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 964212f0..50719aa8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -272,7 +272,7 @@ impl WorkspaceApi { let companion = Arc::new(CompanionConsole::disabled()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); Ok(Self { - records: LocalProjectRecordReader::new(config.workspace_root.clone()), + records: LocalProjectRecordReader::new(config.workspace_root.clone())?, config, store, runtime, @@ -1248,11 +1248,10 @@ async fn scoped_ticket_backend_operation( Json(operation): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let backend = LocalTicketBackend::new( - api.config - .workspace_root - .join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), - ); + let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) + .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; + let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) + .with_record_language(config.ticket_record_language()); let response = match execute_ticket_backend_operation(&backend, operation) { Ok(result) => TicketBackendHttpResponse::Ok { result }, Err(error) => TicketBackendHttpResponse::Error { @@ -5800,6 +5799,53 @@ mod tests { } } + #[tokio::test] + async fn ticket_backend_endpoint_uses_workspace_settings_backend_root() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write( + dir.path().join(".yoi/workspace.toml"), + format!( + "workspace_id = \"{TEST_WORKSPACE_ID}\"\ncreated_at = \"{TEST_CREATED_AT}\"\ndisplay_name = \"Endpoint Test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"server-tickets\"\n" + ), + ) + .unwrap(); + let api = test_api(dir.path()).await; + + let Json(response) = scoped_ticket_backend_operation( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::Create { + input: ticket::NewTicket::new("Endpoint configured root"), + }), + ) + .await + .unwrap_or_else(|error| panic!("ticket backend operation failed: {}", error.error)); + + let ticket_ref = match response { + TicketBackendHttpResponse::Ok { + result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref), + } => ticket_ref, + other => panic!("unexpected ticket backend response: {other:?}"), + }; + assert!( + dir.path() + .join("server-tickets") + .join(&ticket_ref.id) + .join("item.md") + .is_file() + ); + assert!( + !dir.path() + .join(".yoi/tickets") + .join(&ticket_ref.id) + .join("item.md") + .exists() + ); + } + async fn test_api(workspace_root: impl Into) -> WorkspaceApi { let store = SqliteWorkspaceStore::in_memory().unwrap(); WorkspaceApi::new_with_execution_backend( diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index a0960a17..ad2e5580 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -1,11 +1,13 @@ use std::fmt; use std::fs; +use std::hash::{Hash, Hasher}; use std::io::Write; use std::path::{Path, PathBuf}; +use chrono::{SecondsFormat, Utc}; use ticket::config::{ DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, - ticket_config_scaffold, + WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold, }; use ticket::{ LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, @@ -252,11 +254,12 @@ fn run_command( } fn init(workspace: &Path) -> Result { - let config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); - if config_path.exists() { + let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); + if legacy_config_path.exists() { return Err(TicketCliError::new(format!( - "ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", - config_path.display() + "legacy ticket config exists at {}; `.yoi/ticket.config.toml` is obsolete and read-only. Move its policy into {} before running `yoi ticket init`.", + legacy_config_path.display(), + WORKSPACE_SETTINGS_RELATIVE_PATH ))); } @@ -266,28 +269,96 @@ fn init(workspace: &Path) -> Result { fs::create_dir_all(&tickets_dir)?; fs::write(tickets_dir.join(".gitkeep"), b"")?; - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&config_path) - .map_err(|error| { - if error.kind() == std::io::ErrorKind::AlreadyExists { - TicketCliError::new(format!( - "ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", - config_path.display() - )) - } else { - TicketCliError::from(error) - } - })?; - file.write_all(ticket_config_scaffold().as_bytes())?; + let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH); + let scaffold = ticket_config_scaffold(); + let created_settings = if settings_path.exists() { + let content = fs::read_to_string(&settings_path)?; + if TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content)? { + return Err(TicketCliError::new(format!( + "workspace Ticket settings already exist at {}; refusing to overwrite. Edit the [ticket] table manually before running `yoi ticket init`.", + settings_path.display() + ))); + } + let mut file = fs::OpenOptions::new().append(true).open(&settings_path)?; + if !content.ends_with('\n') { + file.write_all(b"\n")?; + } + file.write_all(b"\n")?; + file.write_all(scaffold.as_bytes())?; + false + } else { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&settings_path) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + TicketCliError::new(format!( + "workspace settings already exists at {}; retry `yoi ticket init` to append Ticket settings safely.", + settings_path.display() + )) + } else { + TicketCliError::from(error) + } + })?; + let identity = workspace_settings_identity_header(workspace)?; + file.write_all(identity.as_bytes())?; + file.write_all(b"\n")?; + file.write_all(scaffold.as_bytes())?; + true + }; + let verb = if created_settings { + "created" + } else { + "updated" + }; Ok(success(format!( - "created\t{}\nensured\t{}\n", - TICKET_CONFIG_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH + "{verb}\t{}\nensured\t{}\n", + WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH ))) } +fn workspace_settings_identity_header(workspace: &Path) -> Result { + let display_name = workspace + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("workspace"); + if display_name.contains('\0') || display_name.chars().any(|ch| ch.is_control()) { + return Err(TicketCliError::new( + "workspace display name derived from path must not contain control characters", + )); + } + Ok(format!( + "workspace_id = \"{}\"\ncreated_at = \"{}\"\ndisplay_name = \"{}\"\n", + workspace_settings_uuid_v7(workspace), + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + display_name.replace('\\', "\\\\").replace('"', "\\\"") + )) +} + +fn workspace_settings_uuid_v7(workspace: &Path) -> String { + let now = Utc::now(); + let timestamp_ms = (now.timestamp_millis().max(0) as u64) & 0xffff_ffff_ffff; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + workspace.hash(&mut hasher); + std::process::id().hash(&mut hasher); + now.timestamp_nanos_opt() + .unwrap_or_default() + .hash(&mut hasher); + let random = hasher.finish(); + + let time_low = (timestamp_ms >> 16) as u32; + let time_mid = (timestamp_ms & 0xffff) as u16; + let version_and_rand = 0x7000 | (((random >> 52) as u16) & 0x0fff); + let variant_and_rand = 0x8000 | (((random >> 38) as u16) & 0x3fff); + let node = random & 0xffff_ffff_ffff; + format!( + "{time_low:08x}-{time_mid:04x}-{version_and_rand:04x}-{variant_and_rand:04x}-{node:012x}" + ) +} + fn backend_for_workspace(workspace: &Path) -> Result { let config = TicketConfig::load_workspace(workspace)?; Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()) @@ -1077,7 +1148,7 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title \n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n" } #[cfg(test)] @@ -1106,33 +1177,31 @@ mod tests { } #[test] - fn ticket_cli_init_writes_explicit_ticket_config_scaffold() { + fn ticket_cli_init_writes_explicit_workspace_ticket_settings() { let temp = TempDir::new().unwrap(); let initialized = run(&temp, &["init"]); assert_eq!(initialized.status, TicketCliStatus::Success); - assert!( - initialized - .stdout - .contains("created\t.yoi/ticket.config.toml") - ); + assert!(initialized.stdout.contains("created\t.yoi/workspace.toml")); assert!(initialized.stdout.contains("ensured\t.yoi/tickets")); assert!(temp.path().join(".yoi/tickets").exists()); assert!(temp.path().join(".yoi/tickets/.gitkeep").exists()); - let config = fs::read_to_string(temp.path().join(".yoi/ticket.config.toml")).unwrap(); - assert!(config.contains("[backend]\n")); + let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap(); + assert!(config.contains("workspace_id = \"")); + assert!(config.contains("[ticket]\n")); + assert!(config.contains("[ticket.backend]\n")); assert!(config.contains("provider = \"builtin:yoi_local\"")); assert!(config.contains("root = \".yoi/tickets\"")); - assert!(config.contains("# [ticket]\n# language = \"Japanese\"")); + assert!(config.contains("# language = \"Japanese\"")); for role in TicketRole::ALL { assert!(config.contains(&format!( - "[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", + "[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", role.default_profile(), role.default_workflow() ))); } - assert!(!config.contains("[roles.investigator]")); + assert!(!config.contains("[ticket.roles.investigator]")); } #[test] @@ -1148,9 +1217,9 @@ mod tests { let cli = parse_ticket_args(&args(&["init"])).unwrap(); let err = run_in_workspace(cli, temp.path()).unwrap_err(); - assert!(err.to_string().contains("already exists")); - assert!(err.to_string().contains("refusing to overwrite")); - assert!(err.to_string().contains("yoi ticket init")); + assert!(err.to_string().contains("legacy ticket config exists")); + assert!(err.to_string().contains("obsolete and read-only")); + assert!(err.to_string().contains(WORKSPACE_SETTINGS_RELATIVE_PATH)); assert_eq!( fs::read_to_string(config_path).unwrap(), "[backend]\nprovider = \"builtin:yoi_local\"\n" @@ -1414,8 +1483,8 @@ mod tests { let temp = TempDir::new().unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::write( - temp.path().join(".yoi/ticket.config.toml"), - "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", + temp.path().join(".yoi/workspace.toml"), + "[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", ) .unwrap(); diff --git a/docs/design/workflows-public-dogfood-split.md b/docs/design/workflows-public-dogfood-split.md index 3366fe6d..e6589ecb 100644 --- a/docs/design/workflows-public-dogfood-split.md +++ b/docs/design/workflows-public-dogfood-split.md @@ -30,7 +30,7 @@ This document records the design/audit decision for Ticket `00001KTRKZ14C`. It d | `multi-agent-workflow` | Partly. The sibling coder/reviewer loop, intent packet, branch-local review, and merge-ready dossier are useful product concepts. | Heavy Yoi dogfood specifics: Git worktrees, commits, cargo/nix validation, branch cleanup, docs/report conventions, and parent/child policy. | Split into builtin `multi-agent-workflow` for role loop + dossier, and workspace dogfood extension for Git worktree/cargo/nix mechanics. | | `worktree-workflow` | Not resident public core. | Almost entirely this repository's Git worktree mechanics and `.yoi` path exclusions. | Keep workspace-local dogfood workflow. Optionally create a non-resident builtin `git-worktree-isolation` later, disabled unless explicitly selected. | -## Slug and `.yoi/ticket.config.toml` migration decision +## Slug and workspace Ticket settings migration decision Use explicit dogfood slugs for workflows whose semantics differ from the public builtin workflow. Same-slug workspace overrides are allowed for local wording/policy tweaks of the same public contract, but they should not be used to hide Yoi repository Git/worktree/cargo/nix/merge semantics behind a public slug. This avoids accidental shadowing when a user expects `multi-agent-workflow` to mean the generic builtin role loop. @@ -38,10 +38,10 @@ Planned selector mapping for this repository after builtin workflow loading exis | Role/config surface | Workflow selector | Source intent | Rationale | | --- | --- | --- | --- | -| `[roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. | -| `[roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. | -| `[roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. | -| `[roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. | +| `[ticket.roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. | +| `[ticket.roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. | +| `[ticket.roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. | +| `[ticket.roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. | | compatibility references | `ticket-preflight-workflow` | builtin compatibility alias or workspace compatibility file, non-resident | Do not keep it as a role default. It should point to planning/requirements sync language only. | | dogfood worktree helper | `yoi-dogfood-worktree-workflow` | workspace-local helper, not a role default by itself | Referenced from `yoi-dogfood-multi-agent-workflow`; keeps Git worktree mechanics out of generic builtin workflows. | @@ -115,7 +115,7 @@ Builtin workflows and active workspace workflows should remove or replace the fo 3. **Dogfood workflow retention** - Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics. - - Ensure `.yoi/ticket.config.toml` role workflow selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing. + - Ensure `.yoi/workspace.toml` `[ticket.roles.*].workflow` selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing. 4. **Vocabulary migration** - Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language. diff --git a/docs/development/work-items.md b/docs/development/work-items.md index a76dcd51..a725a367 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -107,31 +107,34 @@ The first version intentionally does not implement roadmap scheduling, milestone ## Ticket configuration -Workspace Ticket orchestration is configured by `.yoi/ticket.config.toml` when present. +Workspace Ticket policy is configured by the tracked workspace settings file `.yoi/workspace.toml` under the `[ticket]` table. The old `.yoi/ticket.config.toml` file is obsolete: current code only reads it as a narrow read-only migration fallback when `.yoi/workspace.toml` has no `[ticket]` table. Workspace settings take precedence as soon as `[ticket]` exists. MVP shape: ```toml -[backend] +[ticket] +language = "Japanese" + +[ticket.backend] provider = "builtin:yoi_local" root = ".yoi/tickets" -[roles.intake] +[ticket.roles.intake] profile = "project:intake" launch_prompt = "$workspace/ticket/intake/launch" workflow = "ticket-intake-workflow" -[roles.orchestrator] +[ticket.roles.orchestrator] profile = "project:orchestrator" launch_prompt = "$workspace/ticket/orchestrator/launch" workflow = "ticket-orchestrator-routing" -[roles.coder] +[ticket.roles.coder] profile = "project:coder" launch_prompt = "$workspace/ticket/coder/launch" workflow = "multi-agent-workflow" -[roles.reviewer] +[ticket.roles.reviewer] profile = "project:reviewer" launch_prompt = "$workspace/ticket/reviewer/launch" workflow = "multi-agent-workflow" @@ -145,10 +148,10 @@ Fixed roles are: - `reviewer` This is not an arbitrary role registry. The fixed roles are the roles required by Ticket orchestration. -Stale `[roles.investigator]` config is rejected as an unsupported fixed role; remove it and, +Stale `[ticket.roles.investigator]` config is rejected as an unsupported fixed role; remove it and, when a spike is useful, let the Orchestrator create an ordinary task-specific read-only helper Worker. -`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. `ticket.config.toml` does not have a role-level `system_instruction` field. +`profile` selects the Worker runtime Profile for that role. The selected Profile owns durable role/system behavior. Workspace Ticket settings do not have a role-level `system_instruction` field. `launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction. @@ -156,7 +159,7 @@ when a spike is useful, let the Orchestrator create an ordinary task-specific re `provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`. -If `.yoi/ticket.config.toml` is missing, defaults are: +If `.yoi/workspace.toml` has no `[ticket]` table and no legacy fallback file exists, defaults are: - backend provider: `builtin:yoi_local` - backend root: `<workspace>/.yoi/tickets` @@ -168,7 +171,7 @@ If `.yoi/ticket.config.toml` is missing, defaults are: - coder: `multi-agent-workflow` - reviewer: `multi-agent-workflow` -Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/ticket.config.toml` before using `yoi panel` role-launch actions. +Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions. ## Workflow lifecycle @@ -272,7 +275,7 @@ Close with a resolution that summarizes what changed, key commits, validation, r `yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Worker Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command. -Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml`: +Role actions map to the same fixed roles configured in `.yoi/workspace.toml` under `[ticket.roles]`: - intake launches the intake role without an existing Ticket and requires freeform context. - route launches the orchestrator role for an existing Ticket. @@ -288,7 +291,7 @@ The role-launch path is: ```text User triggers a Ticket action in yoi panel -> Dashboard builds a TicketRoleLaunchContext - -> client Ticket role launcher reads .yoi/ticket.config.toml + -> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings -> launcher selects the role Profile and workflow -> launcher spawns the role Worker -> launcher sends Method::Run with WorkflowInvoke + Text segments @@ -301,37 +304,37 @@ The launched Worker receives dynamic Ticket/action context as its first committe The first run input contains: - the selected fixed role; -- the workflow canonical id from `.yoi/ticket.config.toml`; +- the workflow canonical id from workspace `[ticket.roles.<role>]` settings; - Ticket id when the action targets an existing Ticket; - freeform user instruction/context from the action; - configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution. -The selected Profile supplies durable system/role behavior. `ticket.config.toml` does not override system instruction. +The selected Profile supplies durable system/role behavior. Workspace Ticket settings do not override system instruction. ### Dashboard setup Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions: ```toml -# .yoi/ticket.config.toml +# .yoi/workspace.toml -[backend] +[ticket.backend] provider = "builtin:yoi_local" root = ".yoi/tickets" -[roles.intake] +[ticket.roles.intake] profile = "project:intake" workflow = "ticket-intake-workflow" -[roles.orchestrator] +[ticket.roles.orchestrator] profile = "project:orchestrator" workflow = "ticket-orchestrator-routing" -[roles.coder] +[ticket.roles.coder] profile = "project:coder" workflow = "multi-agent-workflow" -[roles.reviewer] +[ticket.roles.reviewer] profile = "project:reviewer" workflow = "multi-agent-workflow" ``` @@ -340,8 +343,8 @@ If a role still uses `profile = "inherit"`, the Dashboard fails closed with a di ### Dashboard troubleshooting -- `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`. -- malformed `.yoi/ticket.config.toml`: fix the config and retry. +- `profile = "inherit"`: configure a concrete role Profile in `.yoi/workspace.toml` under `[ticket.roles.<role>]`. +- malformed workspace Ticket settings: fix the `[ticket]` table in `.yoi/workspace.toml` and retry. - missing Ticket id for route, implement, or review actions: provide the target Ticket. - launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.