From fb261bb4f6edfca57ec90c8412d7def57371ada6 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 8 Jun 2026 17:02:16 +0900 Subject: [PATCH] feat: add ticket record language config --- .yoi/ticket.config.toml | 3 + crates/client/src/ticket_role.rs | 40 ++++++- crates/pod/src/feature/builtin/ticket.rs | 14 ++- crates/ticket/src/config.rs | 94 ++++++++++++++++ crates/ticket/src/lib.rs | 136 +++++++++++++++++++++-- crates/ticket/src/tool.rs | 6 +- crates/tui/src/multi_pod.rs | 36 ++++-- crates/tui/src/workspace_panel.rs | 3 +- crates/yoi/src/ticket_cli.rs | 6 +- 9 files changed, 310 insertions(+), 28 deletions(-) diff --git a/.yoi/ticket.config.toml b/.yoi/ticket.config.toml index d8c8210a..3c8278f1 100644 --- a/.yoi/ticket.config.toml +++ b/.yoi/ticket.config.toml @@ -2,6 +2,9 @@ provider = "builtin:yoi_local" root = ".yoi/tickets" +[ticket] +language = "Japanese" + [roles.intake] profile = "project:intake" workflow = "ticket-intake-workflow" diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index e094c300..ca0e793d 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -107,6 +107,7 @@ pub struct TicketRoleLaunchContext { pub ticket: Option, pub user_instruction: Option, pub intake_handoff: Option, + pub ticket_record_language: Option, pub intent_packet: Option, pub worktree_path: Option, pub branch: Option, @@ -123,6 +124,7 @@ impl TicketRoleLaunchContext { ticket: None, user_instruction: None, intake_handoff: None, + ticket_record_language: None, intent_packet: None, worktree_path: None, branch: None, @@ -246,9 +248,12 @@ pub fn plan_ticket_role_launch( /// Construct a launch plan from an already-loaded Ticket config. pub fn plan_ticket_role_launch_with_config( - context: TicketRoleLaunchContext, + mut context: TicketRoleLaunchContext, config: &TicketConfig, ) -> Result { + if context.ticket_record_language.is_none() { + context.ticket_record_language = config.ticket_record_language().map(str::to_string); + } let role_config = config.role_launch_config(context.role)?; let profile = role_config.profile.as_str().to_string(); let workflow = role_config.workflow.as_str().to_string(); @@ -488,6 +493,14 @@ fn build_launch_prompt( None => out.push_str("Configured launch_prompt ref: none\n"), } out.push('\n'); + match non_empty(context.ticket_record_language.as_deref()) { + Some(language) => { + push_bounded_field(&mut out, "Ticket record language", language); + out.push_str("Ticket record language guidance: write durable Ticket item/thread/resolution text and Ticket tool bodies in this language. This does not change normal worker response language or memory/Knowledge generation language. Do not translate protocol literals, file paths, commands, logs, identifiers, or quoted external text solely because this language is configured.\n"); + } + None => out.push_str("Ticket record language: not configured; preserve existing/default Ticket record language behavior.\n"), + } + out.push('\n'); if let Some(ticket) = &context.ticket { ticket.append_prompt_lines(&mut out); @@ -935,6 +948,31 @@ profile = "project:no-such-ticket-role-profile" assert_eq!(plan.profile, "builtin:default"); } + #[test] + fn configured_ticket_record_language_is_included_in_role_prompt() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[ticket] +language = "Japanese" + +[roles.intake] +profile = "builtin:default" +"#, + ); + let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); + + let plan = plan_ticket_role_launch(context).unwrap(); + let text = text_segment(&plan); + + assert!(text.contains("Ticket record language: Japanese")); + assert!(text.contains("write durable Ticket item/thread/resolution text")); + assert!(text.contains("does not change normal worker response language")); + assert!(text.contains("memory/Knowledge generation language")); + assert!(text.contains("Do not translate protocol literals")); + } + #[test] fn scaffold_config_allows_intake_and_orchestrator_launch_planning() { let temp = TempDir::new().unwrap(); diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index 7c09f758..4163b38d 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -43,6 +43,7 @@ impl TicketFeatureAccess { #[derive(Clone, Debug)] pub struct TicketFeature { backend_root: PathBuf, + record_language: Option, config_error: Option, access: TicketFeatureAccess, } @@ -55,6 +56,7 @@ impl TicketFeature { pub fn new_with_access(backend_root: impl Into, access: TicketFeatureAccess) -> Self { Self { backend_root: backend_root.into(), + record_language: None, config_error: None, access, } @@ -70,9 +72,16 @@ impl TicketFeature { ) -> Self { let workspace = workspace.as_ref(); match TicketConfig::load_workspace(workspace) { - Ok(config) => Self::new_with_access(config.backend_root().to_path_buf(), access), + Ok(config) => { + let backend_root = config.backend_root().to_path_buf(); + let record_language = config.ticket_record_language().map(str::to_string); + let mut feature = Self::new_with_access(backend_root, access); + feature.record_language = record_language; + feature + } Err(error) => Self { backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + record_language: None, config_error: Some(error.to_string()), access, }, @@ -149,7 +158,8 @@ impl FeatureModule for TicketFeature { } }; let authority = self.authority(); - let backend = LocalTicketBackend::new(usable_root); + let backend = LocalTicketBackend::new(usable_root) + .with_record_language(self.record_language.as_deref()); let allowed_tool_names = self.access.tool_names(); let mut tools = context.tools(); for definition in ticket_tools(backend) { diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 0787a467..3f6031cc 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -34,6 +34,9 @@ pub fn ticket_config_scaffold() -> String { "root = \"{}\"\n", 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", + ); for role in TicketRole::ALL { out.push_str(&format!( "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", @@ -65,6 +68,7 @@ pub enum TicketConfigError { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketConfig { pub backend: TicketBackendConfig, + pub ticket: TicketRecordConfig, pub roles: TicketRoleProfiles, } @@ -73,6 +77,7 @@ impl TicketConfig { let workspace_root = workspace_root.as_ref(); Self { backend: TicketBackendConfig::default_for_workspace(workspace_root), + ticket: TicketRecordConfig::default(), roles: TicketRoleProfiles::default(), } } @@ -109,6 +114,13 @@ impl TicketConfig { self.backend.root.as_path() } + pub fn ticket_record_language(&self) -> Option<&str> { + self.ticket + .language + .as_ref() + .map(TicketRecordLanguage::as_str) + } + pub fn role(&self, role: TicketRole) -> &TicketRoleConfig { self.roles.get(role) } @@ -231,6 +243,41 @@ impl fmt::Display for TicketRole { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRecordConfig { + pub language: Option, +} + +impl Default for TicketRecordConfig { + fn default() -> Self { + Self { language: None } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRecordLanguage(String); + +impl TicketRecordLanguage { + pub fn new(language: impl Into) -> Result { + let language = normalized_non_empty(language, "ticket record language")?; + Ok(Self(language)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for TicketRecordLanguage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + TicketRecordLanguage::new(value).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRoleProfiles { inner: BTreeMap, @@ -473,9 +520,26 @@ struct RawTicketConfig { #[serde(default)] backend: RawBackendConfig, #[serde(default)] + ticket: RawTicketRecordConfig, + #[serde(default)] roles: BTreeMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTicketRecordConfig { + #[serde(default)] + language: Option, +} + +impl RawTicketRecordConfig { + fn resolve(self) -> TicketRecordConfig { + TicketRecordConfig { + language: self.language, + } + } +} + impl RawTicketConfig { fn resolve( self, @@ -502,6 +566,7 @@ impl RawTicketConfig { message, } })?, + ticket: self.ticket.resolve(), roles, }) } @@ -605,6 +670,7 @@ mod tests { config.backend.root, temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH) ); + assert_eq!(config.ticket_record_language(), None); for role in TicketRole::ALL { let role_config = config.role(role); assert_eq!(role_config.profile.as_str(), "inherit"); @@ -623,6 +689,9 @@ mod tests { provider = "builtin:yoi_local" root = "custom-tickets" +[ticket] +language = "Japanese" + [roles.intake] profile = "project:intake" launch_prompt = "$workspace/ticket/intake/launch" @@ -656,6 +725,7 @@ workflow = "ticket-orchestrator-routing" TicketBackendProvider::BuiltinYoiLocal ); assert_eq!(config.backend.root, temp.path().join("custom-tickets")); + assert_eq!(config.ticket_record_language(), Some("Japanese")); assert_eq!( config.profile_for(TicketRole::Intake).as_str(), "project:intake" @@ -681,6 +751,7 @@ workflow = "ticket-orchestrator-routing" assert!(scaffold.contains("[backend]\n")); assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); assert!(scaffold.contains("root = \".yoi/tickets\"")); + assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); for role in TicketRole::ALL { assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!( @@ -868,6 +939,29 @@ root = "legacy-tickets" assert_eq!(config.backend_root(), temp.path().join("legacy-tickets")); } + #[test] + fn rejects_empty_ticket_record_language() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[backend] +provider = "builtin:yoi_local" +root = ".yoi/tickets" + +[ticket] +language = " " +"#, + ); + + let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("ticket record language must not be empty") + ); + } + #[test] fn unsupported_backend_provider_is_rejected() { let temp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 918f0e18..27887024 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -38,6 +38,26 @@ const REQUIRED_FIELDS: [&str; 11] = [ ]; const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; +const DEFAULT_TICKET_BODY: &str = + "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n"; +const JAPANESE_TICKET_BODY: &str = + "## 背景\n\nLocalTicketBackend によって作成されました。\n\n## 受け入れ条件\n\n- 未定\n"; + +fn normalized_record_language(language: &str) -> Option { + let language = language.trim(); + (!language.is_empty()).then(|| language.to_string()) +} + +fn is_japanese_record_language(language: Option<&str>) -> bool { + let Some(language) = language else { + return false; + }; + let language = language.trim(); + language.eq_ignore_ascii_case("japanese") + || language.eq_ignore_ascii_case("ja") + || language.eq_ignore_ascii_case("ja-JP") + || language.contains("日本語") +} pub type Result = std::result::Result; @@ -492,9 +512,7 @@ impl NewTicket { kind: "task".to_string(), priority: "P2".to_string(), labels: Vec::new(), - body: MarkdownText::new( - "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n", - ), + body: MarkdownText::new(DEFAULT_TICKET_BODY), author: None, assignee: None, legacy_ticket: None, @@ -693,17 +711,86 @@ pub trait TicketBackend { #[derive(Debug, Clone)] pub struct LocalTicketBackend { root: PathBuf, + record_language: Option, } impl LocalTicketBackend { pub fn new(root: impl Into) -> Self { - Self { root: root.into() } + Self { + root: root.into(), + record_language: None, + } + } + + pub fn with_record_language(mut self, language: Option<&str>) -> Self { + self.record_language = language.and_then(normalized_record_language); + self + } + + pub fn record_language(&self) -> Option<&str> { + self.record_language.as_deref() } pub fn root(&self) -> &Path { self.root.as_path() } + pub fn default_intake_ready_state_change_body(&self, from: &str) -> String { + if is_japanese_record_language(self.record_language()) { + format!("Ticket intake が完了しました。workflow_state {from} -> ready。\n") + } else { + format!("Ticket intake complete; workflow_state {from} -> ready.\n") + } + } + + fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str { + if is_japanese_record_language(self.record_language()) { + japanese + } else { + default + } + } + + fn generated_default_body(&self) -> &'static str { + if is_japanese_record_language(self.record_language()) { + JAPANESE_TICKET_BODY + } else { + DEFAULT_TICKET_BODY + } + } + + fn created_event_body(&self) -> &'static str { + if is_japanese_record_language(self.record_language()) { + "LocalTicketBackend によって作成されました。" + } else { + "Created by LocalTicketBackend create." + } + } + + fn queued_ready_body(&self, queued_by: &str) -> String { + if is_japanese_record_language(self.record_language()) { + format!("Ticket を `{queued_by}` が queued にしました。\n") + } else { + "Ticket queued for Orchestrator routing.\n".to_string() + } + } + + fn status_changed_body(&self, status: TicketStatus) -> String { + if is_japanese_record_language(self.record_language()) { + format!("Ticket status を `{}` に変更しました。\n", status.as_str()) + } else { + format!("Status changed to `{}`.\n", status.as_str()) + } + } + + fn closed_workflow_state_body(&self) -> &'static str { + if is_japanese_record_language(self.record_language()) { + "Ticket closed; workflow_state を done に設定しました。\n" + } else { + "Ticket closed; workflow_state set to done.\n" + } + } + fn ensure_backend_dirs(&self) -> Result<()> { for status in STATUSES { let dir = self.status_dir(status); @@ -1099,10 +1186,17 @@ impl TicketBackend for LocalTicketBackend { format_yaml_string_scalar(queued_at.as_str()), )); } - let item = serialize_item(&fields, input.body.as_str()); + let item_body = if input.body.as_str() == DEFAULT_TICKET_BODY { + self.generated_default_body() + } else { + input.body.as_str() + }; + let item = serialize_item(&fields, item_body); atomic_write(&dir.join("item.md"), item.as_bytes())?; let thread = format!( - "{create_comment}\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n" + "{create_comment}\n\n## {}\n\n{}\n\n---\n", + self.generated_heading("Created", "作成"), + self.created_event_body() ); atomic_write(&dir.join("thread.md"), thread.as_bytes())?; Ok(TicketRef { @@ -1243,7 +1337,7 @@ impl TicketBackend for LocalTicketBackend { TicketWorkflowState::Ready.as_str(), TicketWorkflowState::Queued.as_str(), "queued", - "Ticket queued for Orchestrator routing.\n", + self.queued_ready_body(queued_by), ); change.author = Some(queued_by.to_string()); self.apply_workflow_state_change( @@ -1294,11 +1388,11 @@ impl TicketBackend for LocalTicketBackend { } self.set_frontmatter_fields(&new_dir.join("item.md"), &[("status", status.as_str())])?; let author = default_author(); - let body = MarkdownText::new(format!("Status changed to `{}`.\n", status.as_str())); + let body = MarkdownText::new(self.status_changed_body(status)); self.append_thread_event( &new_dir, "status_changed", - "Status changed", + self.generated_heading("Status changed", "ステータス変更"), &author, Some(status.as_str()), &[], @@ -1336,7 +1430,7 @@ impl TicketBackend for LocalTicketBackend { current_workflow_state.as_str(), TicketWorkflowState::Done.as_str(), "closed", - "Ticket closed; workflow_state set to done.\n", + self.closed_workflow_state_body(), ); change.author = Some(default_author()); self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?; @@ -1357,7 +1451,7 @@ impl TicketBackend for LocalTicketBackend { self.append_thread_event( &closed_dir, "close", - "Closed", + self.generated_heading("Closed", "完了"), &author, Some("closed"), &[], @@ -2493,6 +2587,26 @@ workflow_state: intake assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn create_uses_configured_japanese_record_language_for_generated_defaults() { + let tmp = TempDir::new().unwrap(); + let backend = LocalTicketBackend::new(tmp.path().join("tickets")) + .with_record_language(Some("Japanese")); + + let created = backend.create(NewTicket::new("日本語レコード")).unwrap(); + let dir = backend + .root() + .join(TicketStatus::Open.as_str()) + .join(created.id.as_str()); + let item = fs::read_to_string(dir.join("item.md")).unwrap(); + let thread = fs::read_to_string(dir.join("thread.md")).unwrap(); + + assert!(item.contains("## 背景")); + assert!(item.contains("LocalTicketBackend によって作成されました。")); + assert!(thread.contains("## 作成")); + assert!(thread.contains("LocalTicketBackend によって作成されました。")); + } + #[test] fn create_round_trips_numeric_looking_string_frontmatter_values() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 4f170fd6..444aae2c 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -583,10 +583,8 @@ impl Tool for TicketIntakeReadyTool { let from = TicketWorkflowState::Intake; let reason = params.reason.unwrap_or_else(|| "intake_ready".to_string()); let body = params.state_change_body.unwrap_or_else(|| { - format!( - "Ticket intake complete; workflow_state {} -> ready.\n", - from.as_str() - ) + self.backend + .default_intake_ready_state_change_body(from.as_str()) }); let mut summary = TicketIntakeSummary::new(params.intake_summary); summary.author = params.author.clone(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 659f81a3..80887c61 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -1955,7 +1955,8 @@ async fn dispatch_ticket_action( } let config = TicketConfig::load_workspace(&request.workspace_root) .map_err(|error| TicketActionError::BackendConfig(error.to_string()))?; - let backend = LocalTicketBackend::new(config.backend_root()); + let backend = LocalTicketBackend::new(config.backend_root()) + .with_record_language(config.ticket_record_language()); if request.action == NextUserAction::Close { return dispatch_panel_close(&backend, &request.ticket_id); } @@ -2064,7 +2065,7 @@ fn dispatch_panel_close( return Err(TicketActionError::Stale(blocker)); } - let resolution = panel_close_resolution(&ticket); + let resolution = panel_close_resolution(&ticket, backend.record_language()); backend .close(TicketIdOrSlug::Id(ticket_id.to_owned()), resolution) .map_err(|error| TicketActionError::Ticket(error.to_string()))?; @@ -2115,11 +2116,32 @@ fn non_empty_ticket_field(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } -fn panel_close_resolution(ticket: &ticket::Ticket) -> ticket::MarkdownText { - ticket::MarkdownText::new(format!( - "Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `workflow_state: done`.\n\nNo implementation work, workflow-state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n", - ticket.meta.slug, ticket.meta.id - )) +fn panel_close_resolution( + ticket: &ticket::Ticket, + record_language: Option<&str>, +) -> ticket::MarkdownText { + if is_japanese_ticket_record_language(record_language) { + ticket::MarkdownText::new(format!( + "Ticket `{}` (`{}`) はすでに `workflow_state: done` に到達していたため、workspace Panel から close しました。\n\nこの Close action によって、実装作業、workflow-state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n", + ticket.meta.slug, ticket.meta.id + )) + } else { + ticket::MarkdownText::new(format!( + "Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `workflow_state: done`.\n\nNo implementation work, workflow-state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n", + ticket.meta.slug, ticket.meta.id + )) + } +} + +fn is_japanese_ticket_record_language(language: Option<&str>) -> bool { + let Some(language) = language else { + return false; + }; + let language = language.trim(); + language.eq_ignore_ascii_case("japanese") + || language.eq_ignore_ascii_case("ja") + || language.eq_ignore_ascii_case("ja-JP") + || language.contains("日本語") } fn append_panel_decision( diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index a807e8ec..70c0f385 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -552,7 +552,8 @@ fn build_workspace_panel_with_registry_model( match TicketConfig::load_workspace(workspace_root) { Ok(config) => { model.header.ticket_root = config.backend_root().to_path_buf(); - let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()); + let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) + .with_record_language(config.ticket_record_language()); match build_ticket_rows(&backend, pods, registry) { Ok(rows) => model.rows.extend(rows), Err(error) => { diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index b885e183..67d42d7e 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -259,7 +259,8 @@ fn init(workspace: &Path) -> Result { fn backend_for_workspace(workspace: &Path) -> Result { let config = TicketConfig::load_workspace(workspace)?; - Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())) + Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()) + .with_record_language(config.ticket_record_language())) } fn create( @@ -804,7 +805,7 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]\n yoi ticket list [--status open|pending|closed|all]\n yoi ticket show <id-or-slug>\n yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id-or-slug> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket status <id-or-slug> <open|pending|closed>\n yoi ticket close <id-or-slug> (--resolution <text>|--file <path>)\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.\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> [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]\n yoi ticket list [--status open|pending|closed|all]\n yoi ticket show <id-or-slug>\n yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id-or-slug> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket status <id-or-slug> <open|pending|closed>\n yoi ticket close <id-or-slug> (--resolution <text>|--file <path>)\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" } #[cfg(test)] @@ -841,6 +842,7 @@ mod tests { assert!(config.contains("[backend]\n")); assert!(config.contains("provider = \"builtin:yoi_local\"")); assert!(config.contains("root = \".yoi/tickets\"")); + assert!(config.contains("# [ticket]\n# language = \"Japanese\"")); for role in TicketRole::ALL { assert!(config.contains(&format!( "[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"",