diff --git a/.yoi/workflow/ticket-intake-workflow.md b/.yoi/workflow/ticket-intake-workflow.md index 7652b0b3..d729b3e4 100644 --- a/.yoi/workflow/ticket-intake-workflow.md +++ b/.yoi/workflow/ticket-intake-workflow.md @@ -21,6 +21,7 @@ User request / conversation ``` - `Ticket` は durable orchestration record。 +- `Objective` は medium-term goal / motivation / strategy / success criteria / decision context の project record。Objective context は判断背景であり、Ticket body/thread/artifacts を読む代替ではない。 - `Task` は session-local progress tracking。 - `Assignment` は Orchestrator から coder / reviewer Pod、または task-specific helper Pod への具体的委譲。 - `IntentPacket` は Ticket から抽出して Assignment に渡す短い実装・レビュー契約。 @@ -39,6 +40,7 @@ Intake は以下を行う。 - 不足している要件を質問する。 - 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。 - 広い依頼を分割する場合は、進捗コンテナとしての umbrella Ticket ではなく、concrete Ticket / Objective context / split decision record に責務を分ける。 +- Objective-to-Ticket links を提案する場合は canonical opaque Ticket ID だけを使い、dependency / blocking / ordering relation として扱わない。 - Ticket の title / body/request snapshot / acceptance criteria / priority / readiness / action_required / attention_required を、現在の要件として意味がある範囲で提案する。 - canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。 - background / requirements / acceptance criteria / escalation conditions を整理する。 diff --git a/.yoi/workflow/ticket-orchestrator-routing.md b/.yoi/workflow/ticket-orchestrator-routing.md index 3d945419..fd069120 100644 --- a/.yoi/workflow/ticket-orchestrator-routing.md +++ b/.yoi/workflow/ticket-orchestrator-routing.md @@ -39,11 +39,13 @@ Orchestrator は以下を行う。 - Ticket を `TicketShow` で読む。 - 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。 - Ticket body / thread / artifacts / resolution / review / implementation report を読む。 +- Ticket が Objective context と結びついている場合は、Objective を medium-term goal / motivation / strategy / success criteria / decision context として読む。ただし Objective context は判断背景であり、Ticket body/thread/artifacts や explicit Ticket relations / OrchestrationPlan records を読む代替ではない。 - repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。 - queued notification を受けた場合も、Ticket と workspace state を再確認してから routing する。 - next action を routing classification として決める。 - routing decision を `TicketComment` で Ticket thread に記録する。 - broad request や split/refinement では、long-lived umbrella/progress-container Ticket ではなく concrete implementable Ticket、Objective context、split decision record を使う。 +- Objective-to-Ticket links は canonical opaque Ticket ID による non-blocking context link として扱い、dependency / blocking / ordering / ownership / scheduling relation と解釈しない。 - 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。 - implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。 - implementation-ready かつ Ticket が `queued` の場合は、worktree 作成 / implementation Pod `SpawnPod` / coder routing などの side effect の前に、既存の typed Ticket backend/tool path で `queued -> inprogress` を記録する。 diff --git a/Cargo.lock b/Cargo.lock index 555ee93d..14c80c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4772,12 +4772,14 @@ dependencies = [ name = "yoi" version = "0.1.0" dependencies = [ + "chrono", "client", "manifest", "memory", "pod", "serde", "serde_json", + "serde_yaml", "session-store", "tempfile", "ticket", diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index d5b89533..fa8ae7e7 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } client = { workspace = true } memory = { workspace = true } manifest = { workspace = true } @@ -14,6 +15,7 @@ ticket = { workspace = true } tui = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_yaml = "0.9.34" tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } [dev-dependencies] diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index d0805cfb..0c15de02 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1,4 +1,5 @@ mod memory_lint; +mod objective_cli; mod ticket_cli; use std::fmt; @@ -15,6 +16,7 @@ enum Mode { Help, MemoryLintHelp, MemoryLint(LintCliOptions), + Objective(objective_cli::ObjectiveCli), Ticket(ticket_cli::TicketCli), PodRuntime(Vec), Keys, @@ -63,6 +65,19 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::Objective(cli) => match objective_cli::run(cli) { + Ok(output) => { + print!("{}", output.stdout); + match output.status { + objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS, + objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE, + } + } + Err(e) => { + eprintln!("yoi objective: {e}"); + ExitCode::FAILURE + } + }, Mode::Ticket(cli) => match ticket_cli::run(cli) { Ok(output) => { print!("{}", output.stdout); @@ -127,6 +142,11 @@ fn parse_args_slice(args: &[String]) -> Result { match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), "pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())), + "objective" => { + let objective_cli = objective_cli::parse_objective_args(&args[1..]) + .map_err(|e| ParseError(e.to_string()))?; + return Ok(Mode::Objective(objective_cli)); + } "ticket" => { let ticket_cli = ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; @@ -394,7 +414,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs new file mode 100644 index 00000000..0fb3bfc7 --- /dev/null +++ b/crates/yoi/src/objective_cli.rs @@ -0,0 +1,726 @@ +use std::fmt; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use chrono::Utc; +use serde::Deserialize; +use ticket::config::TicketConfig; + +const OBJECTIVE_ROOT_RELATIVE_PATH: &str = ".yoi/objectives"; +const REQUIRED_SECTION_HEADINGS: [&str; 5] = [ + "## Goal", + "## Motivation / background", + "## Strategy / design direction", + "## Success criteria / exit conditions", + "## Decision context", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObjectiveCli { + Help, + Command(ObjectiveCommand), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObjectiveCommand { + Create(CreateOptions), + List(ListOptions), + Show { id: String }, + Doctor, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateOptions { + pub title: String, + pub linked_tickets: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectiveListState { + Active, + Paused, + Done, + Archived, + All, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListOptions { + pub state: ObjectiveListState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectiveCliStatus { + Success, + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectiveCliOutput { + pub status: ObjectiveCliStatus, + pub stdout: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectiveCliError(String); + +impl ObjectiveCliError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ObjectiveCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ObjectiveCliError {} + +impl From for ObjectiveCliError { + fn from(error: std::io::Error) -> Self { + Self::new(error.to_string()) + } +} + +impl From for ObjectiveCliError { + fn from(error: ticket::config::TicketConfigError) -> Self { + Self::new(error.to_string()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObjectiveState { + Active, + Paused, + Done, + Archived, +} + +impl ObjectiveState { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Paused => "paused", + Self::Done => "done", + Self::Archived => "archived", + } + } + + fn parse(value: &str) -> Option { + match value { + "active" => Some(Self::Active), + "paused" => Some(Self::Paused), + "done" => Some(Self::Done), + "archived" => Some(Self::Archived), + _ => None, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct ObjectiveFrontmatter { + title: String, + state: String, + created_at: String, + updated_at: String, + #[serde(default)] + linked_tickets: Vec, +} + +#[derive(Debug, Clone)] +struct ObjectiveRecord { + id: String, + meta: ObjectiveFrontmatter, + body: String, +} + +pub fn parse_objective_args(args: &[String]) -> Result { + if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Ok(ObjectiveCli::Help); + } + + let command = match args[0].as_str() { + "create" => ObjectiveCommand::Create(parse_create(&args[1..])?), + "list" => ObjectiveCommand::List(parse_list(&args[1..])?), + "show" => ObjectiveCommand::Show { + id: parse_one_positional("show", &args[1..])?, + }, + "doctor" => { + if args.len() != 1 { + return Err(ObjectiveCliError::new( + "objective doctor takes no arguments", + )); + } + ObjectiveCommand::Doctor + } + "help" => return Ok(ObjectiveCli::Help), + other => { + return Err(ObjectiveCliError::new(format!( + "unknown objective command: {other}" + ))); + } + }; + + Ok(ObjectiveCli::Command(command)) +} + +pub fn run(cli: ObjectiveCli) -> Result { + let workspace = std::env::current_dir().map_err(|error| { + ObjectiveCliError::new(format!("failed to resolve current directory: {error}")) + })?; + run_in_workspace(cli, &workspace) +} + +pub fn run_in_workspace( + cli: ObjectiveCli, + workspace: &Path, +) -> Result { + match cli { + ObjectiveCli::Help => Ok(success(help_text().to_string())), + ObjectiveCli::Command(ObjectiveCommand::Create(options)) => create(workspace, options), + ObjectiveCli::Command(ObjectiveCommand::List(options)) => list(workspace, options), + ObjectiveCli::Command(ObjectiveCommand::Show { id }) => show(workspace, id), + ObjectiveCli::Command(ObjectiveCommand::Doctor) => doctor(workspace), + } +} + +fn create( + workspace: &Path, + options: CreateOptions, +) -> Result { + let title = options.title.trim(); + if title.is_empty() { + return Err(ObjectiveCliError::new("create --title must not be empty")); + } + validate_ticket_links(workspace, &options.linked_tickets)?; + + let root = objective_root(workspace); + fs::create_dir_all(&root)?; + let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let mut counter = 1_u32; + let (id, dir) = loop { + let candidate = format!("{stamp}-{counter:03}"); + let dir = root.join(&candidate); + if !dir.exists() { + break (candidate, dir); + } + counter += 1; + if counter > 999 { + return Err(ObjectiveCliError::new(format!( + "too many objective id collisions for timestamp {stamp}" + ))); + } + }; + + fs::create_dir_all(&dir)?; + fs::write( + dir.join("item.md"), + render_objective_item(title, &options.linked_tickets), + )?; + Ok(success(format!("created\t{id}\n"))) +} + +fn list(workspace: &Path, options: ListOptions) -> Result { + let mut records = load_objectives(workspace)?; + records.sort_by(|a, b| { + b.meta + .updated_at + .cmp(&a.meta.updated_at) + .then(a.id.cmp(&b.id)) + }); + let mut stdout = String::from("state\tid\ttitle\tupdated_at\tlinked_tickets\n"); + for record in records { + let state = ObjectiveState::parse(&record.meta.state); + if !list_state_matches(options.state, state) { + continue; + } + stdout.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\n", + record.meta.state, + record.id, + record.meta.title, + record.meta.updated_at, + record.meta.linked_tickets.join(",") + )); + } + Ok(success(stdout)) +} + +fn show(workspace: &Path, id: String) -> Result { + validate_record_component(&id)?; + let record = load_objective(&objective_root(workspace).join(&id), &id)?; + let mut stdout = String::new(); + stdout.push_str(&format!("# {}\n\n", record.meta.title)); + stdout.push_str(&format!("State: {}\n", record.meta.state)); + stdout.push_str(&format!("ID: {}\n", record.id)); + stdout.push_str(&format!("Updated: {}\n", record.meta.updated_at)); + stdout.push_str("\n## item.md\n\n---\n"); + stdout.push_str(&format!("title: {}\n", yaml_string(&record.meta.title))); + stdout.push_str(&format!("state: {}\n", yaml_string(&record.meta.state))); + stdout.push_str(&format!( + "created_at: {}\n", + yaml_string(&record.meta.created_at) + )); + stdout.push_str(&format!( + "updated_at: {}\n", + yaml_string(&record.meta.updated_at) + )); + stdout.push_str(&format!( + "linked_tickets: {}\n", + yaml_string_array(&record.meta.linked_tickets) + )); + stdout.push_str("---\n\n"); + stdout.push_str(&record.body); + if !stdout.ends_with('\n') { + stdout.push('\n'); + } + Ok(success(stdout)) +} + +fn doctor(workspace: &Path) -> Result { + let root = objective_root(workspace); + if !root.exists() { + return Ok(success("doctor: ok\n".to_string())); + } + let mut diagnostics = Vec::new(); + for entry in sorted_dirs(&root)? { + let id = entry.file_name().to_string_lossy().to_string(); + if let Err(error) = validate_record_component(&id) { + diagnostics.push(format!("error\t{id}\t{error}")); + continue; + } + match load_objective(&entry.path(), &id) { + Ok(record) => validate_record(workspace, &record, &mut diagnostics)?, + Err(error) => diagnostics.push(format!("error\t{id}\t{error}")), + } + } + if diagnostics.is_empty() { + Ok(success("doctor: ok\n".to_string())) + } else { + let mut stdout = String::new(); + for diagnostic in diagnostics { + stdout.push_str(&diagnostic); + stdout.push('\n'); + } + Ok(ObjectiveCliOutput { + status: ObjectiveCliStatus::Failure, + stdout, + }) + } +} + +fn validate_record( + workspace: &Path, + record: &ObjectiveRecord, + diagnostics: &mut Vec, +) -> Result<(), ObjectiveCliError> { + if record.meta.title.trim().is_empty() { + diagnostics.push(format!("error\t{}\ttitle must not be empty", record.id)); + } + if ObjectiveState::parse(&record.meta.state).is_none() { + diagnostics.push(format!( + "error\t{}\tinvalid state {}; expected active|paused|done|archived", + record.id, record.meta.state + )); + } + for field in [ + record.meta.created_at.as_str(), + record.meta.updated_at.as_str(), + ] { + if field.trim().is_empty() { + diagnostics.push(format!( + "error\t{}\ttimestamps must not be empty", + record.id + )); + } + } + for heading in REQUIRED_SECTION_HEADINGS { + if !record.body.contains(heading) { + diagnostics.push(format!("error\t{}\tmissing section {heading}", record.id)); + } + } + let mut seen = std::collections::BTreeSet::new(); + for ticket_id in &record.meta.linked_tickets { + if !seen.insert(ticket_id) { + diagnostics.push(format!( + "warning\t{}\tduplicate linked ticket {}", + record.id, ticket_id + )); + } + } + if let Err(error) = validate_ticket_links(workspace, &record.meta.linked_tickets) { + diagnostics.push(format!("error\t{}\t{error}", record.id)); + } + Ok(()) +} + +fn load_objectives(workspace: &Path) -> Result, ObjectiveCliError> { + let root = objective_root(workspace); + if !root.exists() { + return Ok(Vec::new()); + } + let mut records = Vec::new(); + for entry in sorted_dirs(&root)? { + let id = entry.file_name().to_string_lossy().to_string(); + records.push(load_objective(&entry.path(), &id)?); + } + Ok(records) +} + +fn load_objective(dir: &Path, id: &str) -> Result { + validate_record_component(id)?; + let path = dir.join("item.md"); + let raw = fs::read_to_string(&path) + .map_err(|error| ObjectiveCliError::new(format!("{}: {error}", path.display())))?; + let (frontmatter, body) = split_frontmatter(&raw).ok_or_else(|| { + ObjectiveCliError::new(format!("{}: missing YAML frontmatter", path.display())) + })?; + let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter).map_err(|error| { + ObjectiveCliError::new(format!( + "{}: invalid YAML frontmatter: {error}", + path.display() + )) + })?; + Ok(ObjectiveRecord { + id: id.to_string(), + meta, + body: body.to_string(), + }) +} + +fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { + let rest = raw.strip_prefix("---\n")?; + let (frontmatter, body) = rest.split_once("\n---\n")?; + Some((frontmatter, body)) +} + +fn validate_ticket_links(workspace: &Path, ticket_ids: &[String]) -> Result<(), ObjectiveCliError> { + let config = TicketConfig::load_workspace(workspace)?; + let ticket_root = config.backend_root().to_path_buf(); + for ticket_id in ticket_ids { + validate_record_component(ticket_id)?; + let item = ticket_root.join(ticket_id).join("item.md"); + if !item.is_file() { + return Err(ObjectiveCliError::new(format!( + "linked ticket {ticket_id} does not exist as canonical Ticket id under {}", + ticket_root.display() + ))); + } + } + Ok(()) +} + +fn validate_record_component(value: &str) -> Result<(), ObjectiveCliError> { + if value.is_empty() || value == "." || value == ".." { + return Err(ObjectiveCliError::new(format!( + "invalid path-derived id component: {value}" + ))); + } + let path = Path::new(value); + if path.components().count() != 1 { + return Err(ObjectiveCliError::new(format!( + "invalid path-derived id component: {value}" + ))); + } + match path.components().next() { + Some(Component::Normal(_)) => Ok(()), + _ => Err(ObjectiveCliError::new(format!( + "invalid path-derived id component: {value}" + ))), + } +} + +fn sorted_dirs(root: &Path) -> Result, ObjectiveCliError> { + let mut entries = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + entries.push(entry); + } + } + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +fn objective_root(workspace: &Path) -> PathBuf { + workspace.join(OBJECTIVE_ROOT_RELATIVE_PATH) +} + +fn list_state_matches(filter: ObjectiveListState, state: Option) -> bool { + match filter { + ObjectiveListState::All => true, + ObjectiveListState::Active => state == Some(ObjectiveState::Active), + ObjectiveListState::Paused => state == Some(ObjectiveState::Paused), + ObjectiveListState::Done => state == Some(ObjectiveState::Done), + ObjectiveListState::Archived => state == Some(ObjectiveState::Archived), + } +} + +fn render_objective_item(title: &str, linked_tickets: &[String]) -> String { + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + format!( + "---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n\n", + yaml_string(title), + yaml_string(ObjectiveState::Active.as_str()), + yaml_string(&now), + yaml_string(&now), + yaml_string_array(linked_tickets) + ) +} + +fn yaml_string(value: &str) -> String { + format!("{:?}", value) +} + +fn yaml_string_array(values: &[String]) -> String { + if values.is_empty() { + return "[]".to_string(); + } + let items = values + .iter() + .map(|value| yaml_string(value)) + .collect::>() + .join(", "); + format!("[{items}]") +} + +fn parse_create(args: &[String]) -> Result { + let mut title = None; + let mut linked_tickets = Vec::new(); + let mut i = 0; + while i < args.len() { + match option_with_value(args, &mut i)? { + Some(("--title", value)) => title = Some(value), + Some(("--ticket", value)) => linked_tickets.push(value), + Some((name, _)) => { + return Err(ObjectiveCliError::new(format!( + "unknown create argument: {name}" + ))); + } + None => { + return Err(ObjectiveCliError::new(format!( + "unknown create argument: {}", + args[i] + ))); + } + } + } + let title = title.ok_or_else(|| ObjectiveCliError::new("create requires --title"))?; + Ok(CreateOptions { + title, + linked_tickets, + }) +} + +fn parse_list(args: &[String]) -> Result { + let mut state = ObjectiveListState::All; + let mut i = 0; + while i < args.len() { + match option_with_value(args, &mut i)? { + Some(("--state", value)) => state = parse_list_state(&value)?, + Some((name, _)) => { + return Err(ObjectiveCliError::new(format!( + "unknown list argument: {name}" + ))); + } + None => { + return Err(ObjectiveCliError::new(format!( + "unknown list argument: {}", + args[i] + ))); + } + } + } + Ok(ListOptions { state }) +} + +fn parse_one_positional(command: &str, args: &[String]) -> Result { + if args.len() != 1 || args[0].starts_with('-') { + Err(ObjectiveCliError::new(format!("{command} requires "))) + } else { + Ok(args[0].clone()) + } +} + +fn option_with_value( + args: &[String], + i: &mut usize, +) -> Result, ObjectiveCliError> { + let arg = &args[*i]; + for name in ["--title", "--ticket", "--state"] { + if arg == name { + let value = args + .get(*i + 1) + .ok_or_else(|| ObjectiveCliError::new(format!("{name} requires a value")))?; + if value.starts_with('-') { + return Err(ObjectiveCliError::new(format!("{name} requires a value"))); + } + *i += 2; + return Ok(Some((name, value.clone()))); + } + if let Some(value) = arg.strip_prefix(&format!("{name}=")) { + if value.is_empty() { + return Err(ObjectiveCliError::new(format!("{name} requires a value"))); + } + *i += 1; + return Ok(Some((name, value.to_string()))); + } + } + Ok(None) +} + +fn parse_list_state(value: &str) -> Result { + match value { + "active" => Ok(ObjectiveListState::Active), + "paused" => Ok(ObjectiveListState::Paused), + "done" => Ok(ObjectiveListState::Done), + "archived" => Ok(ObjectiveListState::Archived), + "all" => Ok(ObjectiveListState::All), + _ => Err(ObjectiveCliError::new(format!( + "invalid objective state: {value}" + ))), + } +} + +fn success(stdout: String) -> ObjectiveCliOutput { + ObjectiveCliOutput { + status: ObjectiveCliStatus::Success, + stdout, + } +} + +fn help_text() -> &'static str { + "yoi objective\n\nUsage:\n yoi objective create --title [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nObjective records are lightweight project records stored as .yoi/objectives/<objective-id>/item.md. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n" +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn args(values: &[&str]) -> Vec<String> { + values.iter().map(|value| value.to_string()).collect() + } + + fn run(temp: &TempDir, values: &[&str]) -> ObjectiveCliOutput { + let cli = parse_objective_args(&args(values)).unwrap(); + run_in_workspace(cli, temp.path()).unwrap() + } + + fn create_ticket_dir(temp: &TempDir, ticket_id: &str) { + let dir = temp.path().join(".yoi/tickets").join(ticket_id); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("item.md"), + "---\ntitle: \"Ticket\"\nstate: \"planning\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\n---\n\nBody\n", + ) + .unwrap(); + } + + fn created_id(output: &ObjectiveCliOutput) -> String { + output + .stdout + .strip_prefix("created\t") + .unwrap() + .trim() + .to_string() + } + + #[test] + fn objective_cli_creates_lists_and_shows_records() { + let temp = TempDir::new().unwrap(); + create_ticket_dir(&temp, "20260608-125430-001"); + + let created = run( + &temp, + &[ + "create", + "--title", + "Medium-term goal", + "--ticket", + "20260608-125430-001", + ], + ); + let objective_id = created_id(&created); + assert!( + temp.path() + .join(".yoi/objectives") + .join(&objective_id) + .join("item.md") + .exists() + ); + + let listed = run(&temp, &["list", "--state", "active"]); + assert!(listed.stdout.contains(&objective_id)); + assert!(listed.stdout.contains("20260608-125430-001")); + + let shown = run(&temp, &["show", &objective_id]); + assert!(shown.stdout.contains("# Medium-term goal")); + assert!( + shown + .stdout + .contains("## Success criteria / exit conditions") + ); + assert!(shown.stdout.contains("20260608-125430-001")); + } + + #[test] + fn objective_cli_validates_ticket_links() { + let temp = TempDir::new().unwrap(); + let cli = parse_objective_args(&args(&[ + "create", + "--title", + "Broken link", + "--ticket", + "missing-ticket", + ])) + .unwrap(); + let err = run_in_workspace(cli, temp.path()).unwrap_err(); + assert!( + err.to_string() + .contains("linked ticket missing-ticket does not exist") + ); + } + + #[test] + fn objective_doctor_reports_invalid_linked_ticket() { + let temp = TempDir::new().unwrap(); + let dir = temp.path().join(".yoi/objectives/20260609-000000-001"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("item.md"), + "---\ntitle: \"Goal\"\nstate: \"active\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\nlinked_tickets: [\"missing\"]\n---\n\n## Goal\n\nText\n\n## Motivation / background\n\nText\n\n## Strategy / design direction\n\nText\n\n## Success criteria / exit conditions\n\n- Text\n\n## Decision context\n\n- Text\n", + ) + .unwrap(); + + let output = run(&temp, &["doctor"]); + assert_eq!(output.status, ObjectiveCliStatus::Failure); + assert!( + output + .stdout + .contains("linked ticket missing does not exist") + ); + } + + #[test] + fn objective_doctor_accepts_well_formed_records() { + let temp = TempDir::new().unwrap(); + create_ticket_dir(&temp, "20260608-125430-001"); + run( + &temp, + &[ + "create", + "--title", + "Good objective", + "--ticket", + "20260608-125430-001", + ], + ); + + let output = run(&temp, &["doctor"]); + assert_eq!(output.status, ObjectiveCliStatus::Success); + assert_eq!(output.stdout, "doctor: ok\n"); + } +} diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 97e55f23..6d751257 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -9,7 +9,7 @@ Do not treat ad-hoc chat summaries, memory records, or Pod notifications as the ## Concepts - `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history. -- `Objective`: medium-term goal, motivation, strategy, and success-criteria context. Objective records are a policy concept here; this document does not define their storage/schema. +- `Objective`: first-class medium-term goal record. It stores goal, motivation/background, strategy/design direction, success criteria/exit conditions, decision context, current Objective lifecycle, and canonical Ticket links under `.yoi/objectives/<objective-id>/item.md`. Objective context is judgment/background context; it is not implementation authority and does not replace reading each Ticket body/thread/artifacts. - `Task`: session-local progress tracking inside a Pod. It is not the project record. - `Assignment`: a concrete delegation from an Orchestrator to a coder/reviewer Pod or task-specific helper Pod. - `IntentPacket`: the short implementation/review contract derived from a Ticket and handed to an Assignment. @@ -23,6 +23,7 @@ A Ticket may represent a feature, bug, cleanup, design decision, investigation, Use the highest-level interface that matches the work: - Use `yoi panel` for the Ticket/Intake/Orchestrator workspace UI and role-launch actions. +- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links. - Inside Pods, use typed Ticket tools to create, inspect, comment, review, and close Tickets. - For multi-step work, follow the Ticket Intake, Orchestrator Routing, planning/requirements-sync, and Multi-agent workflows. @@ -56,6 +57,54 @@ Use them when a Pod needs to materialize or update project records: Do not bypass workflow gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable. +## Objective records + +Objectives are lightweight medium-term project records, not Tickets, Ticket relations, OrchestrationPlan execution records, or Pod/session claims. Use them when a goal spans several concrete Tickets and the durable motivation, design direction, success criteria, or decision context would otherwise be repeated or lost. + +The local Objective surface stores records under: + +```text +.yoi/objectives/<objective-id> + item.md +``` + +`<objective-id>` is the canonical opaque path-derived id. Do not treat Objective titles or slug words as link authority. + +`item.md` uses YAML frontmatter plus Markdown body: + +```yaml +--- +title: "Improve orchestration evidence" +state: "active" # active|paused|done|archived +created_at: "2026-06-09T00:00:00Z" +updated_at: "2026-06-09T00:00:00Z" +linked_tickets: ["20260608-125430-001"] +--- +``` + +The Markdown body should include these sections: + +- `## Goal` +- `## Motivation / background` +- `## Strategy / design direction` +- `## Success criteria / exit conditions` +- `## Decision context` + +Linked Tickets must be canonical opaque Ticket ids that exist in the configured Ticket backend root. Objective-to-Ticket links are context links only: they are not dependency, blocking, ordering, ownership, or scheduling relations. Use typed Ticket relations for Ticket-to-Ticket dependency/blocking/related metadata, OrchestrationPlan records for routing/execution plans, and Pod/session claims for runtime ownership hints. + +Objective lifecycle is only Objective lifecycle. `active`, `paused`, `done`, and `archived` do not drive Ticket `state`, do not authorize implementation, and do not close linked Tickets. A role reading Objective context must still inspect each Ticket body, thread, artifacts, explicit Ticket relations, and OrchestrationPlan records before acting. + +The maintainer CLI is: + +```sh +yoi objective create --title "..." [--ticket <ticket-id> ...] +yoi objective list [--state active|paused|done|archived|all] +yoi objective show <objective-id> +yoi objective doctor +``` + +The first version intentionally does not implement roadmap scheduling, milestones, OKRs, graph solving, Objective-mandatory Ticket creation, Objective thread/artifact history, or broad panel UX. Future UX can surface Objective context around Tickets as long as it remains background context and never substitutes for reading the Ticket. + ## Ticket configuration Workspace Ticket orchestration is configured by `.yoi/ticket.config.toml` when present. diff --git a/package.nix b/package.nix index 7d732e0e..d1d4d5a1 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-uxmc3RsNb+ivbe9wnJcqLRWWRjU2uloF2HMvgZ6L0dI="; + cargoHash = "sha256-OGjxH5/HbcOAZllczaw4gg+yBTcrH6407+8xUtfLObY="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,