merge: workspace ticket settings

This commit is contained in:
Keisuke Hirata 2026-07-16 02:45:52 +09:00
commit 45783b7488
No known key found for this signature in database
14 changed files with 691 additions and 223 deletions

View File

@ -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"

View File

@ -1,3 +1,26 @@
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6" workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
created_at = "2026-06-23T00:00:00Z" created_at = "2026-06-23T00:00:00Z"
display_name = "yoi" 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"

View File

@ -253,7 +253,7 @@ pub enum TicketRoleLaunchError {
#[error(transparent)] #[error(transparent)]
LaunchConfig(#[from] TicketRoleLaunchConfigError), LaunchConfig(#[from] TicketRoleLaunchConfigError),
#[error( #[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 { ProfileResolution {
role: TicketRole, role: TicketRole,
@ -287,7 +287,7 @@ pub enum TicketRoleLaunchError {
RunAcceptanceTimeout, 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( pub fn plan_ticket_role_launch(
context: TicketRoleLaunchContext, context: TicketRoleLaunchContext,
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> { ) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
@ -700,14 +700,14 @@ mod tests {
fn write_config(workspace: &std::path::Path, content: &str) { fn write_config(workspace: &std::path::Path, content: &str) {
let dir = workspace.join(".yoi"); let dir = workspace.join(".yoi");
std::fs::create_dir_all(&dir).unwrap(); 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]) { 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 { for role in roles {
config.push_str(&format!( config.push_str(&format!(
"\n[roles.{role}]\nprofile = \"builtin:default\"\n" "\n[ticket.roles.{role}]\nprofile = \"builtin:default\"\n"
)); ));
} }
write_config(workspace, &config); write_config(workspace, &config);
@ -883,7 +883,7 @@ mod tests {
err.to_string() err.to_string()
.contains("Ticket role `coder` is not launch-configured") .contains("Ticket role `coder` is not launch-configured")
); );
assert!(err.to_string().contains("[roles.coder]")); assert!(err.to_string().contains("[ticket.roles.coder]"));
} }
#[test] #[test]
@ -913,7 +913,7 @@ root = ".yoi/tickets"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.intake] [ticket.roles.intake]
profile = "inherit" profile = "inherit"
"#, "#,
); );
@ -931,7 +931,7 @@ profile = "inherit"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.intake] [ticket.roles.intake]
profile = "project:no-such-ticket-role-profile" 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" "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] #[test]
@ -968,7 +968,7 @@ profile = "project:no-such-ticket-role-profile"
[ticket] [ticket]
language = "Japanese" language = "Japanese"
[roles.intake] [ticket.roles.intake]
profile = "builtin:default" profile = "builtin:default"
"#, "#,
); );
@ -1035,7 +1035,7 @@ profile = "builtin:default"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.reviewer] [ticket.roles.reviewer]
profile = "builtin:default" profile = "builtin:default"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "ticket-review-workflow" workflow = "ticket-review-workflow"
@ -1226,7 +1226,7 @@ workflow = "ticket-review-workflow"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.coder] [ticket.roles.coder]
profile = "./coder.toml" profile = "./coder.toml"
"#, "#,
); );
@ -1243,7 +1243,7 @@ profile = "./coder.toml"
write_config( write_config(
temp.path(), temp.path(),
r#" r#"
[roles.coder] [ticket.roles.coder]
profile = "inherit" profile = "inherit"
system_instruction = "$workspace/not-supported" system_instruction = "$workspace/not-supported"
"#, "#,

View File

@ -1,9 +1,12 @@
//! Workspace-local Ticket orchestration configuration. //! Workspace-local Ticket orchestration configuration.
//! //!
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root. //! Durable Ticket policy lives under the `[ticket]` table in tracked
//! It intentionally stores lightweight string references for Profile selectors, //! `.yoi/workspace.toml` workspace settings. The legacy
//! launch prompts, and workflows so this crate remains independent from `worker` //! `.yoi/ticket.config.toml` file is only a read-only migration fallback when
//! and `manifest` runtime resolution. //! 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::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
@ -13,6 +16,8 @@ use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; 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"; pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
/// Workspace-relative default root for the built-in local Ticket backend. /// Workspace-relative default root for the built-in local Ticket backend.
pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; 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_DIR: &str = ".worktree";
const DEFAULT_ORCHESTRATION_WORKTREE_NAME: &str = "orchestration"; 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 /// The scaffold is a valid `.yoi/workspace.toml` fragment rooted at `[ticket]`.
/// profile so strict role launch planning can validate the config without runtime /// It intentionally configures every fixed Ticket role with a concrete profile
/// so strict role launch planning can validate the config without runtime
/// fallback. /// fallback.
pub fn ticket_config_scaffold() -> String { 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!( out.push_str(&format!(
"provider = \"{}\"\n", "provider = \"{}\"\n",
TicketBackendProvider::BuiltinYoiLocal.as_str() TicketBackendProvider::BuiltinYoiLocal.as_str()
@ -36,14 +45,11 @@ pub fn ticket_config_scaffold() -> String {
DEFAULT_TICKET_BACKEND_RELATIVE_PATH DEFAULT_TICKET_BACKEND_RELATIVE_PATH
)); ));
out.push_str( out.push_str(
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\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",
);
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",
); );
for role in TicketRole::ALL { for role in TicketRole::ALL {
out.push_str(&format!( out.push_str(&format!(
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", "\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
role.default_profile(), role.default_profile(),
role.default_workflow() role.default_workflow()
)); ));
@ -222,15 +228,81 @@ impl TicketConfig {
pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> { pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); let workspace_settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
let content = match fs::read_to_string(&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, Ok(content) => content,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => { Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default_for_workspace(workspace_root)); 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>,
path: impl AsRef<Path>,
content: &str,
) -> Result<Option<Self>, 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<Path>,
content: &str,
) -> Result<bool, TicketConfigError> {
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( pub fn from_toml(
@ -487,11 +559,11 @@ impl Default for TicketRoleProfiles {
#[derive(Debug, Clone, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TicketRoleLaunchConfigError { pub enum TicketRoleLaunchConfigError {
#[error( #[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 }, MissingRoleTable { role: TicketRole },
#[error( #[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 }, MissingProfile { role: TicketRole },
#[error( #[error(
@ -675,6 +747,19 @@ struct RawTicketConfig {
roles: BTreeMap<String, RawTicketRoleConfig>, roles: BTreeMap<String, RawTicketRoleConfig>,
} }
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawWorkspaceTicketConfig {
#[serde(default)]
backend: RawBackendConfig,
#[serde(default)]
language: Option<TicketRecordLanguage>,
#[serde(default)]
orchestration: RawTicketOrchestrationConfig,
#[serde(default)]
roles: BTreeMap<String, RawTicketRoleConfig>,
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawTicketOrchestrationConfig { struct RawTicketOrchestrationConfig {
@ -722,9 +807,47 @@ impl RawTicketConfig {
self, self,
workspace_root: &Path, workspace_root: &Path,
path: &Path, path: &Path,
) -> Result<TicketConfig, TicketConfigError> {
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<TicketConfig, TicketConfigError> {
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<String, RawTicketRoleConfig>,
workspace_root: &Path,
path: &Path,
) -> Result<TicketConfig, TicketConfigError> { ) -> Result<TicketConfig, TicketConfigError> {
let mut roles = TicketRoleProfiles::default(); let mut roles = TicketRoleProfiles::default();
for (name, raw_role) in self.roles { for (name, raw_role) in raw_roles {
let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid { let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
path: path.to_path_buf(), path: path.to_path_buf(),
message: format!( message: format!(
@ -740,23 +863,22 @@ impl RawTicketConfig {
} }
} }
Ok(TicketConfig { Ok(TicketConfig {
backend: self.backend.resolve(workspace_root).map_err(|message| { backend: backend
TicketConfigError::Invalid { .resolve(workspace_root)
.map_err(|message| TicketConfigError::Invalid {
path: path.to_path_buf(), path: path.to_path_buf(),
message, message,
}
})?, })?,
ticket: self.ticket.resolve(), ticket,
orchestration: self.orchestration.resolve().map_err(|message| { orchestration: orchestration
TicketConfigError::Invalid { .resolve()
.map_err(|message| TicketConfigError::Invalid {
path: path.to_path_buf(), path: path.to_path_buf(),
message, message,
}
})?, })?,
roles, roles,
}) })
} }
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -843,6 +965,12 @@ mod tests {
std::fs::write(dir.join("ticket.config.toml"), content).unwrap(); 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] #[test]
fn missing_config_returns_documented_defaults() { fn missing_config_returns_documented_defaults() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -876,39 +1004,123 @@ mod tests {
} }
#[test] #[test]
fn full_config_parses_fixed_role_refs() { fn workspace_settings_take_precedence_over_legacy_ticket_config() {
let temp = TempDir::new().unwrap(); 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( write_config(
temp.path(), temp.path(),
r#" r#"
[backend] [backend]
provider = "builtin:yoi_local" 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] [ticket]
language = "Japanese" 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" branch = "orchestration/custom-panel"
worktree_dir = "custom-worktrees" worktree_dir = "custom-worktrees"
worktree_name = "custom-orchestrator" worktree_name = "custom-orchestrator"
[roles.intake] [ticket.roles.intake]
profile = "project:intake" profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch" launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow" workflow = "ticket-intake-workflow"
[roles.orchestrator] [ticket.roles.orchestrator]
profile = "project:orchestrator" profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch" launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing" workflow = "ticket-orchestrator-routing"
[roles.coder] [ticket.roles.coder]
profile = "inherit" profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch" launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
[roles.reviewer] [ticket.roles.reviewer]
profile = "project:reviewer" profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
@ -960,28 +1172,30 @@ workflow = "multi-agent-workflow"
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let scaffold = ticket_config_scaffold(); 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("provider = \"builtin:yoi_local\""));
assert!(scaffold.contains("root = \".yoi/tickets\"")); assert!(scaffold.contains("root = \".yoi/tickets\""));
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); assert!(scaffold.contains("# language = \"Japanese\""));
assert!(scaffold.contains( 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 { for role in TicketRole::ALL {
assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
assert!(scaffold.contains(&format!( assert!(scaffold.contains(&format!(
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", "[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(), role.default_profile(),
role.default_workflow() 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(),
temp.path().join(TICKET_CONFIG_RELATIVE_PATH), temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH),
&scaffold, &scaffold,
) )
.unwrap()
.unwrap(); .unwrap();
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
assert_eq!(config.orchestration.branch_name(), None); assert_eq!(config.orchestration.branch_name(), None);

View File

@ -94,13 +94,13 @@ fn ensure_and_restore_use_configured_orchestration_layout() {
write_test_ticket_config( write_test_ticket_config(
&root, &root,
r#" r#"
[orchestration] [ticket.orchestration]
branch = "orchestration/custom-panel" branch = "orchestration/custom-panel"
worktree_dir = "custom-worktrees" worktree_dir = "custom-worktrees"
worktree_name = "panel" 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(); run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
let resolved = resolved_orchestration_worktree_layout(&root).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( write_test_ticket_config(
&root, &root,
r#" r#"
[orchestration] [ticket.orchestration]
branch = "orchestration/bad:branch" branch = "orchestration/bad:branch"
"#, "#,
); );
@ -145,11 +145,11 @@ fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout()
write_test_ticket_config( write_test_ticket_config(
&root, &root,
r#" r#"
[orchestration] [ticket.orchestration]
branch = "orchestration/custom-panel" 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(); run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
let layout = resolved_orchestration_worktree_layout(&root).unwrap(); let layout = resolved_orchestration_worktree_layout(&root).unwrap();
run_test_git( 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) { fn write_test_ticket_config(root: &Path, content: &str) {
let config_dir = root.join(".yoi"); let config_dir = root.join(".yoi");
std::fs::create_dir_all(&config_dir).unwrap(); 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) { fn init_test_repo(root: &Path) {
@ -337,8 +337,8 @@ fn ticket_workspace(
) )
.unwrap(); .unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
) )
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); 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] #[tokio::test]
async fn ticket_action_rejects_stale_absent_config_without_mutation() { async fn ticket_action_rejects_stale_absent_config_without_mutation() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config"); 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 = let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) 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(); let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
) )
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));

View File

@ -6,8 +6,8 @@ use std::time::Instant;
use protocol::WorkerStatus; use protocol::WorkerStatus;
use ticket::config::{ use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TicketOrchestrationConfig,
TicketOrchestrationConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, 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 { pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); let settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
match config_path.symlink_metadata() { 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!( Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
"{} exists but is not a regular file", "{} exists but is not a regular file",
TICKET_CONFIG_RELATIVE_PATH TICKET_CONFIG_RELATIVE_PATH
@ -608,12 +641,13 @@ fn load_orchestration_ticket_overlay(
} }
match validate_orchestration_overlay_source(workspace_root, &layout) { match validate_orchestration_overlay_source(workspace_root, &layout) {
Ok(()) => { Ok(()) => {
load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language()) load_orchestration_ticket_overlay_states(&layout.path).unwrap_or_else(|message| {
.unwrap_or_else(|message| OrchestrationTicketOverlay { OrchestrationTicketOverlay {
states: BTreeMap::new(), states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!( diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration Ticket overlay unavailable: {message}" "Orchestration Ticket overlay unavailable: {message}"
))], ))],
}
}) })
} }
Err(message) => OrchestrationTicketOverlay { Err(message) => OrchestrationTicketOverlay {
@ -639,19 +673,25 @@ fn orchestration_worktree_layout(
fn load_orchestration_ticket_overlay_states( fn load_orchestration_ticket_overlay_states(
worktree_root: &Path, worktree_root: &Path,
record_language: Option<&str>,
) -> Result<OrchestrationTicketOverlay, String> { ) -> Result<OrchestrationTicketOverlay, String> {
let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH); let overlay_config = TicketConfig::load_workspace(worktree_root).map_err(|error| {
format!(
"orchestration worktree {} has unusable Ticket settings: {error}",
worktree_root.display()
)
})?;
let ticket_root = overlay_config.backend_root();
if !ticket_root.is_dir() { if !ticket_root.is_dir() {
return Ok(OrchestrationTicketOverlay { return Ok(OrchestrationTicketOverlay {
states: BTreeMap::new(), states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!( diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration worktree has no {} directory", "Orchestration worktree configured Ticket backend root {} is not a directory",
DEFAULT_TICKET_BACKEND_RELATIVE_PATH ticket_root.display()
))], ))],
}); });
} }
let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language); let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
.with_record_language(overlay_config.ticket_record_language());
let partial = backend let partial = backend
.list_partial(TicketFilter::all()) .list_partial(TicketFilter::all())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -1763,11 +1803,17 @@ mod tests {
} }
fn write_ticket_config(workspace_root: &Path) { fn write_ticket_config(workspace_root: &Path) {
write_ticket_config_with_root(workspace_root, ".yoi/tickets");
}
fn write_ticket_config_with_root(workspace_root: &Path, root: &str) {
let config_dir = workspace_root.join(".yoi"); let config_dir = workspace_root.join(".yoi");
fs::create_dir_all(&config_dir).unwrap(); fs::create_dir_all(&config_dir).unwrap();
fs::write( fs::write(
config_dir.join("ticket.config.toml"), config_dir.join("workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", format!(
"[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"{root}\"\n"
),
) )
.unwrap(); .unwrap();
} }
@ -1815,8 +1861,12 @@ mod tests {
} }
fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) { fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) {
copy_ticket_to_overlay_root(workspace_root, &orchestration_root.join(".yoi/tickets"), id);
}
fn copy_ticket_to_overlay_root(workspace_root: &Path, overlay_root: &Path, id: &str) {
let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id); let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id);
let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id); let overlay_ticket_dir = overlay_root.join(id);
fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap(); fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap();
fs::create_dir_all(&overlay_ticket_dir).unwrap(); fs::create_dir_all(&overlay_ticket_dir).unwrap();
fs::copy( fs::copy(
@ -1987,6 +2037,40 @@ mod tests {
assert!(model.rows.iter().all(|row| row.title != "Overlay Only")); assert!(model.rows.iter().all(|row| row.title != "Overlay Only"));
} }
#[test]
fn workspace_panel_orchestration_overlay_uses_configured_backend_root() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
write_ticket_config(temp.path());
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
write_ticket_config_with_root(&orchestration_root, "configured-overlay-tickets");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let id = create_ticket_with_id(&backend, "Overlay Custom Root", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
let configured_overlay_root = orchestration_root.join("configured-overlay-tickets");
copy_ticket_to_overlay_root(temp.path(), &configured_overlay_root, &id);
let overlay_backend = LocalTicketBackend::new(&configured_overlay_root);
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
let model = build_workspace_panel(temp.path(), &empty_pods());
assert!(model.header.diagnostics.is_empty());
let row = ticket_row_by_title(&model, "Overlay Custom Root");
assert_eq!(row.status, "q→prog");
assert_eq!(
row.ticket
.as_ref()
.unwrap()
.orchestration_overlay
.as_ref()
.unwrap()
.workflow_state,
TicketWorkflowState::InProgress
);
assert!(!orchestration_root.join(".yoi/tickets").exists());
}
#[test] #[test]
fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket() fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket()
{ {
@ -2233,8 +2317,8 @@ mod tests {
let config_dir = temp.path().join(".yoi"); let config_dir = temp.path().join(".yoi");
fs::create_dir_all(&config_dir).unwrap(); fs::create_dir_all(&config_dir).unwrap();
fs::write( fs::write(
config_dir.join("ticket.config.toml"), config_dir.join("workspace.toml"),
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n", "[ticket]\n\n[ticket.backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
) )
.unwrap(); .unwrap();
@ -2678,11 +2762,11 @@ mod tests {
} }
#[test] #[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 temp = TempDir::new().unwrap();
let config_parent = temp.path().join(".yoi"); let config_parent = temp.path().join(".yoi");
fs::create_dir_all(&config_parent).unwrap(); 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!( assert!(matches!(
ticket_config_availability(temp.path()), ticket_config_availability(temp.path()),

View File

@ -736,8 +736,8 @@ where
"ticket tools require local Worker filesystem authority", "ticket tools require local Worker filesystem authority",
) )
})?; })?;
crate::feature::builtin::ticket::TicketFeatureBackend::Local { crate::feature::builtin::ticket::TicketFeatureBackend::LocalWorkspace {
root: ticket_cwd.clone(), workspace_root: ticket_cwd.clone(),
} }
} }
}; };

View File

@ -61,6 +61,9 @@ pub enum TicketFeatureBackend {
Local { Local {
root: PathBuf, root: PathBuf,
}, },
LocalWorkspace {
workspace_root: PathBuf,
},
WorkspaceHttp { WorkspaceHttp {
workspace_id: String, workspace_id: String,
base_url: String, base_url: String,
@ -125,6 +128,13 @@ impl TicketFeature {
access: Option<TicketFeatureAccess>, access: Option<TicketFeatureAccess>,
include_orchestration_tools: bool, include_orchestration_tools: bool,
) -> Self { ) -> Self {
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
return Self::for_workspace_with_options(
workspace_root,
access,
include_orchestration_tools,
);
}
Self { Self {
backend, backend,
record_language: None, record_language: None,
@ -180,6 +190,7 @@ impl TicketFeature {
pub fn backend_root(&self) -> Option<&Path> { pub fn backend_root(&self) -> Option<&Path> {
match &self.backend { match &self.backend {
TicketFeatureBackend::Local { root } => Some(root), TicketFeatureBackend::Local { root } => Some(root),
TicketFeatureBackend::LocalWorkspace { workspace_root } => Some(workspace_root),
TicketFeatureBackend::WorkspaceHttp { .. } => None, TicketFeatureBackend::WorkspaceHttp { .. } => None,
} }
} }
@ -219,7 +230,8 @@ impl TicketFeature {
} }
fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> { fn tool_backend(&self, context: &mut FeatureInstallContext<'_>) -> Option<TicketToolBackend> {
match &self.backend { match &self.backend {
TicketFeatureBackend::Local { root: _ } => { TicketFeatureBackend::Local { root: _ }
| TicketFeatureBackend::LocalWorkspace { workspace_root: _ } => {
let usable_root = match self.usable_backend_root() { let usable_root = match self.usable_backend_root() {
Ok(root) => root, Ok(root) => root,
Err(reason) => { Err(reason) => {
@ -606,7 +618,7 @@ mod tests {
fn write_ticket_config(workspace: &Path, content: &str) { fn write_ticket_config(workspace: &Path, content: &str) {
let yoi_dir = workspace.join(".yoi"); let yoi_dir = workspace.join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap(); 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( fn pending_tool_description(
@ -853,11 +865,11 @@ language = "Japanese"
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
r#" r#"
[backend] [ticket.backend]
provider = "builtin:yoi_local" provider = "builtin:yoi_local"
root = "tickets" root = "tickets"
[roles.coder] [ticket.roles.coder]
profile = "project:coder" profile = "project:coder"
"#, "#,
); );
@ -886,7 +898,7 @@ profile = "project:coder"
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
r#" r#"
[roles.operator] [ticket.roles.operator]
profile = "inherit" profile = "inherit"
"#, "#,
); );
@ -911,7 +923,7 @@ profile = "inherit"
write_ticket_config( write_ticket_config(
temp.path(), temp.path(),
r#" r#"
[backend] [ticket.backend]
provider = "github" provider = "github"
"#, "#,
); );

View File

@ -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. /// Stable local Workspace identity persisted as a tracked, safe project record.
/// ///
/// The v0 TOML schema intentionally contains identity metadata only: /// The v0 TOML schema contains identity metadata plus optional tracked project
/// `workspace_id`, `created_at`, and `display_name`. Unknown fields are rejected /// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
/// instead of preserved because this loader cannot safely round-trip future local /// here because this loader cannot safely round-trip future local runtime settings
/// runtime settings without risking accidental path or secret persistence. /// without risking accidental path or secret persistence.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceIdentity { pub struct WorkspaceIdentity {
pub workspace_id: String, pub workspace_id: String,
@ -29,6 +29,8 @@ struct WorkspaceIdentityFile {
workspace_id: String, workspace_id: String,
created_at: String, created_at: String,
display_name: String, display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ticket: Option<toml::Value>,
} }
impl WorkspaceIdentity { impl WorkspaceIdentity {
@ -112,6 +114,7 @@ impl WorkspaceIdentity {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
created_at: self.created_at.clone(), created_at: self.created_at.clone(),
display_name: self.display_name.clone(), display_name: self.display_name.clone(),
ticket: None,
}) })
.map_err(|error| { .map_err(|error| {
workspace_identity_error(path, format!("failed to encode TOML: {error}")) workspace_identity_error(path, format!("failed to encode TOML: {error}"))

View File

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id; use project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
use crate::{Error, Result}; use crate::{Error, Result};
@ -17,13 +18,16 @@ pub struct LocalProjectRecordReader {
} }
impl LocalProjectRecordReader { impl LocalProjectRecordReader {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self { pub fn new(workspace_root: impl Into<PathBuf>) -> Result<Self> {
let workspace_root = workspace_root.into(); let workspace_root = workspace_root.into();
let ticket_root = workspace_root.join(".yoi/tickets"); let ticket_config = TicketConfig::load_workspace(&workspace_root)
Self { .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, workspace_root,
ticket_backend: LocalTicketBackend::new(ticket_root), ticket_backend,
} })
} }
pub fn workspace_root(&self) -> &Path { pub fn workspace_root(&self) -> &Path {
@ -297,7 +301,7 @@ mod tests {
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
write_objective(dir.path(), "00000000001J3", "Control plane", "active"); 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(); let tickets = reader.list_tickets(20).unwrap();
assert_eq!(tickets.record_authority, "local_yoi_project_records"); assert_eq!(tickets.record_authority, "local_yoi_project_records");
assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].id, "00000000001J2");
@ -313,9 +317,41 @@ mod tests {
let objective = reader.objective("00000000001J3").unwrap(); let objective = reader.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body")); 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) { 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::create_dir_all(&ticket_dir).unwrap();
fs::write( fs::write(
ticket_dir.join("item.md"), ticket_dir.join("item.md"),

View File

@ -272,7 +272,7 @@ impl WorkspaceApi {
let companion = Arc::new(CompanionConsole::disabled()); let companion = Arc::new(CompanionConsole::disabled());
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self { Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()), records: LocalProjectRecordReader::new(config.workspace_root.clone())?,
config, config,
store, store,
runtime, runtime,
@ -1248,11 +1248,10 @@ async fn scoped_ticket_backend_operation(
Json(operation): Json<TicketBackendOperation>, Json(operation): Json<TicketBackendOperation>,
) -> ApiResult<Json<TicketBackendHttpResponse>> { ) -> ApiResult<Json<TicketBackendHttpResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let backend = LocalTicketBackend::new( let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
api.config .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
.workspace_root let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), .with_record_language(config.ticket_record_language());
);
let response = match execute_ticket_backend_operation(&backend, operation) { let response = match execute_ticket_backend_operation(&backend, operation) {
Ok(result) => TicketBackendHttpResponse::Ok { result }, Ok(result) => TicketBackendHttpResponse::Ok { result },
Err(error) => TicketBackendHttpResponse::Error { 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<PathBuf>) -> WorkspaceApi { async fn test_api(workspace_root: impl Into<PathBuf>) -> WorkspaceApi {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
WorkspaceApi::new_with_execution_backend( WorkspaceApi::new_with_execution_backend(

View File

@ -1,11 +1,13 @@
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use ticket::config::{ use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
ticket_config_scaffold, WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
@ -252,11 +254,12 @@ fn run_command(
} }
fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> { fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
let config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH);
if config_path.exists() { if legacy_config_path.exists() {
return Err(TicketCliError::new(format!( return Err(TicketCliError::new(format!(
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", "legacy ticket config exists at {}; `.yoi/ticket.config.toml` is obsolete and read-only. Move its policy into {} before running `yoi ticket init`.",
config_path.display() legacy_config_path.display(),
WORKSPACE_SETTINGS_RELATIVE_PATH
))); )));
} }
@ -266,28 +269,96 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
fs::create_dir_all(&tickets_dir)?; fs::create_dir_all(&tickets_dir)?;
fs::write(tickets_dir.join(".gitkeep"), b"")?; fs::write(tickets_dir.join(".gitkeep"), b"")?;
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() let mut file = fs::OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
.open(&config_path) .open(&settings_path)
.map_err(|error| { .map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists { if error.kind() == std::io::ErrorKind::AlreadyExists {
TicketCliError::new(format!( TicketCliError::new(format!(
"ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", "workspace settings already exists at {}; retry `yoi ticket init` to append Ticket settings safely.",
config_path.display() settings_path.display()
)) ))
} else { } else {
TicketCliError::from(error) TicketCliError::from(error)
} }
})?; })?;
file.write_all(ticket_config_scaffold().as_bytes())?; 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!( Ok(success(format!(
"created\t{}\nensured\t{}\n", "{verb}\t{}\nensured\t{}\n",
TICKET_CONFIG_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
))) )))
} }
fn workspace_settings_identity_header(workspace: &Path) -> Result<String, TicketCliError> {
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<LocalTicketBackend, TicketCliError> { fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
let config = TicketConfig::load_workspace(workspace)?; let config = TicketConfig::load_workspace(workspace)?;
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()) Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
@ -1077,7 +1148,7 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { fn help_text() -> &'static str {
"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 .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)] #[cfg(test)]
@ -1106,33 +1177,31 @@ mod tests {
} }
#[test] #[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 temp = TempDir::new().unwrap();
let initialized = run(&temp, &["init"]); let initialized = run(&temp, &["init"]);
assert_eq!(initialized.status, TicketCliStatus::Success); assert_eq!(initialized.status, TicketCliStatus::Success);
assert!( assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
initialized
.stdout
.contains("created\t.yoi/ticket.config.toml")
);
assert!(initialized.stdout.contains("ensured\t.yoi/tickets")); assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
assert!(temp.path().join(".yoi/tickets").exists()); assert!(temp.path().join(".yoi/tickets").exists());
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists()); assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
let config = fs::read_to_string(temp.path().join(".yoi/ticket.config.toml")).unwrap(); let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
assert!(config.contains("[backend]\n")); 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("provider = \"builtin:yoi_local\""));
assert!(config.contains("root = \".yoi/tickets\"")); assert!(config.contains("root = \".yoi/tickets\""));
assert!(config.contains("# [ticket]\n# language = \"Japanese\"")); assert!(config.contains("# language = \"Japanese\""));
for role in TicketRole::ALL { for role in TicketRole::ALL {
assert!(config.contains(&format!( assert!(config.contains(&format!(
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", "[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(), role.default_profile(),
role.default_workflow() role.default_workflow()
))); )));
} }
assert!(!config.contains("[roles.investigator]")); assert!(!config.contains("[ticket.roles.investigator]"));
} }
#[test] #[test]
@ -1148,9 +1217,9 @@ mod tests {
let cli = parse_ticket_args(&args(&["init"])).unwrap(); let cli = parse_ticket_args(&args(&["init"])).unwrap();
let err = run_in_workspace(cli, temp.path()).unwrap_err(); let err = run_in_workspace(cli, temp.path()).unwrap_err();
assert!(err.to_string().contains("already exists")); assert!(err.to_string().contains("legacy ticket config exists"));
assert!(err.to_string().contains("refusing to overwrite")); assert!(err.to_string().contains("obsolete and read-only"));
assert!(err.to_string().contains("yoi ticket init")); assert!(err.to_string().contains(WORKSPACE_SETTINGS_RELATIVE_PATH));
assert_eq!( assert_eq!(
fs::read_to_string(config_path).unwrap(), fs::read_to_string(config_path).unwrap(),
"[backend]\nprovider = \"builtin:yoi_local\"\n" "[backend]\nprovider = \"builtin:yoi_local\"\n"
@ -1414,8 +1483,8 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/workspace.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", "[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
) )
.unwrap(); .unwrap();

View File

@ -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. | | `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. | | `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. 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 | | 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. | | `[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. |
| `[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.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`. | | `[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`. |
| `[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.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. | | 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. | | 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** 3. **Dogfood workflow retention**
- Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics. - 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** 4. **Vocabulary migration**
- Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language. - Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language.

View File

@ -107,31 +107,34 @@ The first version intentionally does not implement roadmap scheduling, milestone
## Ticket configuration ## 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: MVP shape:
```toml ```toml
[backend] [ticket]
language = "Japanese"
[ticket.backend]
provider = "builtin:yoi_local" provider = "builtin:yoi_local"
root = ".yoi/tickets" root = ".yoi/tickets"
[roles.intake] [ticket.roles.intake]
profile = "project:intake" profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch" launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow" workflow = "ticket-intake-workflow"
[roles.orchestrator] [ticket.roles.orchestrator]
profile = "project:orchestrator" profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch" launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing" workflow = "ticket-orchestrator-routing"
[roles.coder] [ticket.roles.coder]
profile = "project:coder" profile = "project:coder"
launch_prompt = "$workspace/ticket/coder/launch" launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
[roles.reviewer] [ticket.roles.reviewer]
profile = "project:reviewer" profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
@ -145,10 +148,10 @@ Fixed roles are:
- `reviewer` - `reviewer`
This is not an arbitrary role registry. The fixed roles are the roles required by Ticket orchestration. 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. 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. `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`. `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 provider: `builtin:yoi_local`
- backend root: `<workspace>/.yoi/tickets` - backend root: `<workspace>/.yoi/tickets`
@ -168,7 +171,7 @@ If `.yoi/ticket.config.toml` is missing, defaults are:
- coder: `multi-agent-workflow` - coder: `multi-agent-workflow`
- reviewer: `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 ## 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. `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. - intake launches the intake role without an existing Ticket and requires freeform context.
- route launches the orchestrator role for an existing Ticket. - route launches the orchestrator role for an existing Ticket.
@ -288,7 +291,7 @@ The role-launch path is:
```text ```text
User triggers a Ticket action in yoi panel User triggers a Ticket action in yoi panel
-> Dashboard builds a TicketRoleLaunchContext -> 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 selects the role Profile and workflow
-> launcher spawns the role Worker -> launcher spawns the role Worker
-> launcher sends Method::Run with WorkflowInvoke + Text segments -> 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 first run input contains:
- the selected fixed role; - 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; - Ticket id when the action targets an existing Ticket;
- freeform user instruction/context from the action; - freeform user instruction/context from the action;
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution. - 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 ### Dashboard setup
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions: Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions:
```toml ```toml
# .yoi/ticket.config.toml # .yoi/workspace.toml
[backend] [ticket.backend]
provider = "builtin:yoi_local" provider = "builtin:yoi_local"
root = ".yoi/tickets" root = ".yoi/tickets"
[roles.intake] [ticket.roles.intake]
profile = "project:intake" profile = "project:intake"
workflow = "ticket-intake-workflow" workflow = "ticket-intake-workflow"
[roles.orchestrator] [ticket.roles.orchestrator]
profile = "project:orchestrator" profile = "project:orchestrator"
workflow = "ticket-orchestrator-routing" workflow = "ticket-orchestrator-routing"
[roles.coder] [ticket.roles.coder]
profile = "project:coder" profile = "project:coder"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
[roles.reviewer] [ticket.roles.reviewer]
profile = "project:reviewer" profile = "project:reviewer"
workflow = "multi-agent-workflow" workflow = "multi-agent-workflow"
``` ```
@ -340,8 +343,8 @@ If a role still uses `profile = "inherit"`, the Dashboard fails closed with a di
### Dashboard troubleshooting ### Dashboard troubleshooting
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`. - `profile = "inherit"`: configure a concrete role Profile in `.yoi/workspace.toml` under `[ticket.roles.<role>]`.
- malformed `.yoi/ticket.config.toml`: fix the config and retry. - 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. - 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. - launch success but no visible completion: attach to or inspect the launched Worker; completion notifications are hints, not authority.