merge: add objective records
This commit is contained in:
commit
be69a8b06f
|
|
@ -21,6 +21,7 @@ User request / conversation
|
||||||
```
|
```
|
||||||
|
|
||||||
- `Ticket` は durable orchestration record。
|
- `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。
|
- `Task` は session-local progress tracking。
|
||||||
- `Assignment` は Orchestrator から coder / reviewer Pod、または task-specific helper Pod への具体的委譲。
|
- `Assignment` は Orchestrator から coder / reviewer Pod、または task-specific helper Pod への具体的委譲。
|
||||||
- `IntentPacket` は Ticket から抽出して Assignment に渡す短い実装・レビュー契約。
|
- `IntentPacket` は Ticket から抽出して Assignment に渡す短い実装・レビュー契約。
|
||||||
|
|
@ -39,6 +40,7 @@ Intake は以下を行う。
|
||||||
- 不足している要件を質問する。
|
- 不足している要件を質問する。
|
||||||
- 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。
|
- 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。
|
||||||
- 広い依頼を分割する場合は、進捗コンテナとしての umbrella Ticket ではなく、concrete Ticket / Objective context / split decision record に責務を分ける。
|
- 広い依頼を分割する場合は、進捗コンテナとしての 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 を、現在の要件として意味がある範囲で提案する。
|
- Ticket の title / body/request snapshot / acceptance criteria / priority / readiness / action_required / attention_required を、現在の要件として意味がある範囲で提案する。
|
||||||
- canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。
|
- canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。
|
||||||
- background / requirements / acceptance criteria / escalation conditions を整理する。
|
- background / requirements / acceptance criteria / escalation conditions を整理する。
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,13 @@ Orchestrator は以下を行う。
|
||||||
- Ticket を `TicketShow` で読む。
|
- Ticket を `TicketShow` で読む。
|
||||||
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。
|
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。
|
||||||
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
|
- 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 を必要に応じて明示的に確認する。
|
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
|
||||||
- queued notification を受けた場合も、Ticket と workspace state を再確認してから routing する。
|
- queued notification を受けた場合も、Ticket と workspace state を再確認してから routing する。
|
||||||
- next action を routing classification として決める。
|
- next action を routing classification として決める。
|
||||||
- routing decision を `TicketComment` で Ticket thread に記録する。
|
- routing decision を `TicketComment` で Ticket thread に記録する。
|
||||||
- broad request や split/refinement では、long-lived umbrella/progress-container Ticket ではなく concrete implementable Ticket、Objective context、split decision record を使う。
|
- 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 を検討する。
|
- 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。
|
||||||
- implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。
|
- 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` を記録する。
|
- implementation-ready かつ Ticket が `queued` の場合は、worktree 作成 / implementation Pod `SpawnPod` / coder routing などの side effect の前に、既存の typed Ticket backend/tool path で `queued -> inprogress` を記録する。
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4772,12 +4772,14 @@ dependencies = [
|
||||||
name = "yoi"
|
name = "yoi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"client",
|
"client",
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
"memory",
|
||||||
"pod",
|
"pod",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"session-store",
|
"session-store",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"ticket",
|
"ticket",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
client = { workspace = true }
|
client = { workspace = true }
|
||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
|
|
@ -14,6 +15,7 @@ ticket = { workspace = true }
|
||||||
tui = { workspace = true }
|
tui = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
serde_yaml = "0.9.34"
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod memory_lint;
|
mod memory_lint;
|
||||||
|
mod objective_cli;
|
||||||
mod ticket_cli;
|
mod ticket_cli;
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
@ -15,6 +16,7 @@ enum Mode {
|
||||||
Help,
|
Help,
|
||||||
MemoryLintHelp,
|
MemoryLintHelp,
|
||||||
MemoryLint(LintCliOptions),
|
MemoryLint(LintCliOptions),
|
||||||
|
Objective(objective_cli::ObjectiveCli),
|
||||||
Ticket(ticket_cli::TicketCli),
|
Ticket(ticket_cli::TicketCli),
|
||||||
PodRuntime(Vec<String>),
|
PodRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
|
|
@ -63,6 +65,19 @@ async fn main() -> ExitCode {
|
||||||
ExitCode::FAILURE
|
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) {
|
Mode::Ticket(cli) => match ticket_cli::run(cli) {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
print!("{}", output.stdout);
|
print!("{}", output.stdout);
|
||||||
|
|
@ -127,6 +142,11 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
match args[0].as_str() {
|
match args[0].as_str() {
|
||||||
"--help" | "-h" => return Ok(Mode::Help),
|
"--help" | "-h" => return Ok(Mode::Help),
|
||||||
"pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())),
|
"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" => {
|
"ticket" => {
|
||||||
let ticket_cli =
|
let ticket_cli =
|
||||||
ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?;
|
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() {
|
fn print_help() {
|
||||||
println!(
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
726
crates/yoi/src/objective_cli.rs
Normal file
726
crates/yoi/src/objective_cli.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ Do not treat ad-hoc chat summaries, memory records, or Pod notifications as the
|
||||||
## Concepts
|
## Concepts
|
||||||
|
|
||||||
- `Ticket`: durable project/orchestration record. It contains requirements, decisions, plans, implementation reports, reviews, artifacts, and resolution history.
|
- `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.
|
- `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.
|
- `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.
|
- `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 the highest-level interface that matches the work:
|
||||||
|
|
||||||
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace UI and role-launch actions.
|
- 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.
|
- 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.
|
- 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.
|
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
|
## Ticket configuration
|
||||||
|
|
||||||
Workspace Ticket orchestration is configured by `.yoi/ticket.config.toml` when present.
|
Workspace Ticket orchestration is configured by `.yoi/ticket.config.toml` when present.
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-uxmc3RsNb+ivbe9wnJcqLRWWRjU2uloF2HMvgZ6L0dI=";
|
cargoHash = "sha256-OGjxH5/HbcOAZllczaw4gg+yBTcrH6407+8xUtfLObY=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user