merge: add objective records

This commit is contained in:
Keisuke Hirata 2026-06-09 15:56:29 +09:00
commit be69a8b06f
No known key found for this signature in database
8 changed files with 806 additions and 3 deletions

View File

@ -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 を整理する。

View File

@ -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` を記録する。

2
Cargo.lock generated
View File

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

View File

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

View File

@ -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<String>),
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<Mode, ParseError> {
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<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}

View File

@ -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<String>,
}
#[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<String>) -> 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<std::io::Error> for ObjectiveCliError {
fn from(error: std::io::Error) -> Self {
Self::new(error.to_string())
}
}
impl From<ticket::config::TicketConfigError> 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<Self> {
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<String>,
}
#[derive(Debug, Clone)]
struct ObjectiveRecord {
id: String,
meta: ObjectiveFrontmatter,
body: String,
}
pub fn parse_objective_args(args: &[String]) -> Result<ObjectiveCli, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<String>,
) -> 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<Vec<ObjectiveRecord>, 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<ObjectiveRecord, ObjectiveCliError> {
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<Vec<fs::DirEntry>, 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<ObjectiveState>) -> 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::<Vec<_>>()
.join(", ");
format!("[{items}]")
}
fn parse_create(args: &[String]) -> Result<CreateOptions, ObjectiveCliError> {
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<ListOptions, ObjectiveCliError> {
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<String, ObjectiveCliError> {
if args.len() != 1 || args[0].starts_with('-') {
Err(ObjectiveCliError::new(format!("{command} requires <id>")))
} else {
Ok(args[0].clone())
}
}
fn option_with_value(
args: &[String],
i: &mut usize,
) -> Result<Option<(&'static str, String)>, 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<ObjectiveListState, ObjectiveCliError> {
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 <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");
}
}

View File

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

View File

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