ticket: add typed relation metadata

This commit is contained in:
Keisuke Hirata 2026-06-09 15:17:45 +09:00
parent 41a61d79d9
commit 4601ad2b41
No known key found for this signature in database
6 changed files with 1616 additions and 16 deletions

View File

@ -69,9 +69,10 @@ Orchestrator は以下を行う。
利用可能なら、以下を使う。 利用可能なら、以下を使う。
- `TicketList`: routing 候補や関連 Ticket の確認。 - `TicketList`: routing 候補や関連 Ticket の確認。
- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution 確認。 - `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。 - `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。 - `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
- `TicketRelationQuery`: project-level の forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) を読む。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。 - `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。 - `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。 - `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
@ -81,6 +82,9 @@ Orchestrator は以下を行う。
## Queued acceptance contract ## Queued acceptance contract
- `queued -> inprogress` acceptance の直前に `TicketShow` / `TicketRelationQuery` の relation blockers を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
- Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。
`state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。 `state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。
- unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。 - unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。

File diff suppressed because it is too large Load Diff

View File

@ -13,10 +13,10 @@ use serde_json::{Value, json};
use crate::{ use crate::{
AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord,
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, Ticket, TicketBackend,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange, TicketIdOrSlug, TicketIntakeSummary, TicketRelationKind, TicketReview, TicketReviewResult,
TicketSummary, TicketWorkflowState, TicketStateChange, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 100; const DEFAULT_LIST_LIMIT: usize = 100;
@ -30,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500; const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 11] = [ pub const TICKET_TOOL_NAMES: [&str; 13] = [
"TicketCreate", "TicketCreate",
"TicketList", "TicketList",
"TicketShow", "TicketShow",
@ -39,25 +39,29 @@ pub const TICKET_TOOL_NAMES: [&str; 11] = [
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord",
"TicketRelationQuery",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
"TicketDoctor", "TicketDoctor",
]; ];
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
"TicketDoctor", "TicketDoctor",
]; ];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
"TicketCreate", "TicketCreate",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
]; ];
@ -85,6 +89,11 @@ transition is accepted and recorded. Orchestrator may return `ready` or `queued`
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \ const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \ backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event."; a close event.";
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \
inverse views are derived, not stored.";
const RELATION_QUERY_DESCRIPTION: &str = "Query durable typed Ticket relation metadata. When a Ticket \
is provided, both outgoing records owned by it and incoming forward records that target it are returned.";
const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \ const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \
for ordering, dependency, conflict, waiting/capacity, or accepted-plan decisions. Records are durable \ for ordering, dependency, conflict, waiting/capacity, or accepted-plan decisions. Records are durable \
Ticket artifacts and do not move state, reorder queues, or start work."; Ticket artifacts and do not move state, reorder queues, or start work.";
@ -311,6 +320,65 @@ struct TicketCloseParams {
resolution: String, resolution: String,
} }
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketRelationKindParam {
DependsOn,
Blocks,
Related,
Supersedes,
DuplicateOf,
}
impl TicketRelationKindParam {
fn into_kind(self) -> TicketRelationKind {
match self {
Self::DependsOn => TicketRelationKind::DependsOn,
Self::Blocks => TicketRelationKind::Blocks,
Self::Related => TicketRelationKind::Related,
Self::Supersedes => TicketRelationKind::Supersedes,
Self::DuplicateOf => TicketRelationKind::DuplicateOf,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketRelationRecordParams {
/// Ticket id that owns the forward relation.
ticket: String,
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
kind: TicketRelationKindParam,
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
target: String,
/// Optional bounded rationale/note.
#[serde(default)]
note: Option<String>,
/// Optional record author.
#[serde(default)]
author: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketRelationQueryParams {
/// Optional Ticket id to query. Includes outgoing and incoming forward records for that id.
#[serde(default)]
ticket: Option<String>,
/// Optional forward relation kind filter.
#[serde(default)]
kind: Option<TicketRelationKindParam>,
/// Maximum records to return. Defaults to 100, max 200.
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
struct TicketRelationQueryOutput {
count: usize,
returned: usize,
truncated: bool,
relations: Vec<Value>,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum OrchestrationPlanKindParam { enum OrchestrationPlanKindParam {
@ -467,6 +535,16 @@ struct TicketCloseTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
} }
#[derive(Clone)]
struct TicketRelationRecordTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketRelationQueryTool {
backend: LocalTicketBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketOrchestrationPlanRecordTool { struct TicketOrchestrationPlanRecordTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
@ -715,6 +793,64 @@ impl Tool for TicketCloseTool {
} }
} }
#[async_trait]
impl Tool for TicketRelationRecordTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketRelationRecordParams = parse_input("TicketRelationRecord", input_json)?;
let relation = NewTicketRelation {
kind: params.kind.into_kind(),
target: params.target.clone(),
note: params.note,
author: params.author,
};
let output = self
.backend
.add_ticket_relation(TicketIdOrSlug::Id(params.ticket.clone()), relation)
.map_err(|error| backend_error("TicketRelationRecord", error))?;
Ok(json_output(
format!(
"Recorded ticket relation {} {} {}",
output.ticket_id, output.kind, output.target
),
ticket_relation_json(&output),
))
}
}
#[async_trait]
impl Tool for TicketRelationQueryTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketRelationQueryParams = parse_input("TicketRelationQuery", input_json)?;
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let ticket = params.ticket.clone().map(TicketIdOrSlug::Id);
let kind = params.kind.map(TicketRelationKindParam::into_kind);
let relations = self
.backend
.query_ticket_relations(ticket, kind)
.map_err(|error| backend_error("TicketRelationQuery", error))?;
let count = relations.len();
let truncated = count > limit;
let returned_relations = relations
.into_iter()
.take(limit)
.map(|relation| ticket_relation_json(&relation))
.collect::<Vec<_>>();
Ok(json_output(
format!(
"Found {} ticket relation(s){}",
count,
if truncated { " (truncated)" } else { "" }
),
TicketRelationQueryOutput {
count,
returned: returned_relations.len(),
truncated,
relations: returned_relations,
},
))
}
}
#[async_trait] #[async_trait]
impl Tool for TicketOrchestrationPlanRecordTool { impl Tool for TicketOrchestrationPlanRecordTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> { async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@ -849,6 +985,73 @@ fn ticket_summary_json(ticket: TicketSummary) -> Value {
}) })
} }
fn ticket_relation_json(relation: &crate::TicketRelation) -> Value {
json!({
"ticket_id": relation.ticket_id,
"kind": relation.kind.as_str(),
"target": relation.target,
"note": relation.note,
"author": relation.author,
"at": relation.at,
})
}
fn ticket_relations_json(ticket: &Ticket) -> Value {
let outgoing: Vec<_> = ticket
.relations
.outgoing
.iter()
.map(ticket_relation_json)
.collect();
let incoming: Vec<_> = ticket
.relations
.incoming
.iter()
.map(|relation| {
json!({
"source_ticket": relation.source_ticket,
"inverse_kind": relation.inverse_kind,
"forward_kind": relation.forward_kind.as_str(),
"note": relation.note,
"author": relation.author,
"at": relation.at,
})
})
.collect();
let blockers: Vec<_> = ticket
.relations
.blockers
.iter()
.map(|blocker| {
json!({
"blocking_ticket": blocker.blocking_ticket,
"reason_kind": blocker.reason_kind,
"relation_kind": blocker.relation_kind.as_str(),
"note": blocker.note,
"blocking_state": blocker.blocking_state.as_str(),
})
})
.collect();
let notices: Vec<_> = ticket
.relations
.notices
.iter()
.map(|notice| {
json!({
"related_ticket": notice.related_ticket,
"kind": notice.kind.as_str(),
"message": notice.message,
})
})
.collect();
json!({
"outgoing": outgoing,
"incoming": incoming,
"blockers": blockers,
"notices": notices,
})
}
fn ticket_json( fn ticket_json(
ticket: &Ticket, ticket: &Ticket,
event_limit: usize, event_limit: usize,
@ -911,6 +1114,7 @@ fn ticket_json(
"truncated": artifact_count > artifacts.len(), "truncated": artifact_count > artifacts.len(),
"items": artifacts, "items": artifacts,
}, },
"relations": ticket_relations_json(ticket),
"resolution": ticket.resolution.as_ref().map(|resolution| truncate_text(resolution.as_str(), body_max_bytes)), "resolution": ticket.resolution.as_ref().map(|resolution| truncate_text(resolution.as_str(), body_max_bytes)),
}) })
} }
@ -997,6 +1201,12 @@ fn input_schema(name: &str) -> Value {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
} }
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketRelationRecord" => {
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
}
"TicketRelationQuery" => {
serde_json::to_value(schemars::schema_for!(TicketRelationQueryParams))
}
"TicketOrchestrationPlanRecord" => { "TicketOrchestrationPlanRecord" => {
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams)) serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
} }
@ -1027,6 +1237,8 @@ impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketRelationRecordTool);
impl_from_backend!(TicketRelationQueryTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool); impl_from_backend!(TicketDoctorTool);
@ -1050,6 +1262,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
backend.clone(), backend.clone(),
), ),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()), tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketRelationRecordTool>(
"TicketRelationRecord",
RELATION_RECORD_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketRelationQueryTool>(
"TicketRelationQuery",
RELATION_QUERY_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketOrchestrationPlanRecordTool>( tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
ORCHESTRATION_PLAN_RECORD_DESCRIPTION, ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
@ -1095,6 +1317,7 @@ mod tests {
[ [
"TicketList", "TicketList",
"TicketShow", "TicketShow",
"TicketRelationQuery",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
"TicketDoctor" "TicketDoctor"
] ]
@ -1108,6 +1331,7 @@ mod tests {
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketClose", "TicketClose",
"TicketRelationRecord",
"TicketOrchestrationPlanRecord" "TicketOrchestrationPlanRecord"
] ]
); );
@ -1187,6 +1411,53 @@ mod tests {
assert!(report.summary.contains("0 error(s)")); assert!(report.summary.contains("0 error(s)"));
} }
#[tokio::test]
async fn ticket_relation_tools_record_query_and_show_derived_view() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let source = backend.create(NewTicket::new("Relation Source")).unwrap();
let target = backend.create(NewTicket::new("Relation Target")).unwrap();
let record = tool_by_name(backend.clone(), "TicketRelationRecord");
let query = tool_by_name(backend.clone(), "TicketRelationQuery");
let show = tool_by_name(backend.clone(), "TicketShow");
let recorded = record
.execute(
&json!({
"ticket": source.id.clone(),
"kind": "depends_on",
"target": target.id.clone(),
"note": "target first",
"author": "test"
})
.to_string(),
)
.await
.unwrap();
assert!(recorded.summary.contains("Recorded ticket relation"));
let recorded_json: Value = serde_json::from_str(&recorded.content.unwrap()).unwrap();
assert_eq!(recorded_json["kind"], "depends_on");
assert_eq!(recorded_json["target"], target.id);
let queried = query
.execute(&json!({ "ticket": target.id.clone() }).to_string())
.await
.unwrap();
let queried_json: Value = serde_json::from_str(&queried.content.unwrap()).unwrap();
assert_eq!(queried_json["count"], 1);
assert_eq!(queried_json["relations"][0]["ticket_id"], source.id);
let shown = show
.execute(&json!({ "id": target.id.clone() }).to_string())
.await
.unwrap();
let shown_json: Value = serde_json::from_str(&shown.content.unwrap()).unwrap();
assert_eq!(
shown_json["relations"]["incoming"][0]["inverse_kind"],
"dependency_of"
);
}
#[tokio::test] #[tokio::test]
async fn ticket_tools_comment_review_state_and_close_are_doctor_clean() { async fn ticket_tools_comment_review_state_and_close_are_doctor_clean() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -4,7 +4,7 @@ use protocol::PodStatus;
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
use ticket::{ use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
TicketMeta, TicketSummary, TicketWorkflowState, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
}; };
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
@ -601,7 +601,13 @@ pub(crate) fn build_current_ticket_row(
} }
let summary = ticket_summary_from_meta(&ticket.meta); let summary = ticket_summary_from_meta(&ticket.meta);
let registry = PanelRegistrySnapshot::empty(); let registry = PanelRegistrySnapshot::empty();
Ok(ticket_row(summary, &ticket.events, pods, &registry)) Ok(ticket_row(
summary,
&ticket.events,
&ticket.relations.blockers,
pods,
&registry,
))
} }
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
@ -635,7 +641,13 @@ fn build_ticket_rows(
continue; continue;
} }
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?; let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
rows.push(ticket_row(summary, &ticket.events, pods, registry)); rows.push(ticket_row(
summary,
&ticket.events,
&ticket.relations.blockers,
pods,
registry,
));
} }
Ok(rows) Ok(rows)
} }
@ -643,12 +655,13 @@ fn build_ticket_rows(
fn ticket_row( fn ticket_row(
summary: TicketSummary, summary: TicketSummary,
events: &[TicketEvent], events: &[TicketEvent],
relation_blockers: &[TicketRelationBlocker],
pods: &PodList, pods: &PodList,
registry: &PanelRegistrySnapshot, registry: &PanelRegistrySnapshot,
) -> PanelRow { ) -> PanelRow {
let local_claim = local_claim_for_ticket(&summary, pods, registry); let local_claim = local_claim_for_ticket(&summary, pods, registry);
let related_pods = related_pods_for_ticket(&summary, pods, registry); let related_pods = related_pods_for_ticket(&summary, pods, registry);
let derived = derive_ticket_state(&summary); let derived = derive_ticket_state(&summary, relation_blockers);
let latest_event = events.last(); let latest_event = events.last();
let entry = TicketPanelEntry { let entry = TicketPanelEntry {
id: summary.id.clone(), id: summary.id.clone(),
@ -691,7 +704,37 @@ struct DerivedTicketState {
blocked_reason: Option<String>, blocked_reason: Option<String>,
} }
fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState { fn derive_ticket_state(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
) -> DerivedTicketState {
if !relation_blockers.is_empty() {
let blockers = relation_blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
return DerivedTicketState {
kind: PanelRowKind::Blocked,
priority: ActionPriority::UserReply,
action: Some(NextUserAction::Edit),
disabled_reason: Some(
"Unresolved Ticket relation blocks queueing; resolve dependency/blocker before ready -> queued."
.to_string(),
),
key_hint: Some("Open the Ticket relation diagnostics before queueing".to_string()),
blocked_reason: Some(blockers),
};
}
if let Some(reason) = summary if let Some(reason) = summary
.attention_required .attention_required
.as_deref() .as_deref()
@ -945,7 +988,7 @@ mod tests {
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tempfile::TempDir; use tempfile::TempDir;
use ticket::{NewTicket, TicketWorkflowState}; use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState};
fn empty_pods() -> PodList { fn empty_pods() -> PodList {
PodList::from_sources( PodList::from_sources(
@ -1105,6 +1148,56 @@ mod tests {
assert_eq!(queued.next_action, Some(NextUserAction::Wait)); assert_eq!(queued.next_action, Some(NextUserAction::Wait));
} }
#[test]
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_blocked() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut ready_input = NewTicket::new("Ready Blocked By Relation");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let dependency = backend
.create(NewTicket::new("Relation Dependency"))
.unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(ready.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = model
.rows
.iter()
.find(|row| row.title == "Ready Blocked By Relation")
.unwrap();
assert_eq!(row.kind, PanelRowKind::Blocked);
assert_eq!(row.next_action, Some(NextUserAction::Edit));
assert_eq!(row.priority, ActionPriority::UserReply);
assert!(
row.disabled_reason
.as_deref()
.unwrap()
.contains("Unresolved Ticket relation")
);
assert!(
row.ticket
.as_ref()
.unwrap()
.blocked_reason
.as_deref()
.unwrap()
.contains(&dependency.id)
);
}
#[test] #[test]
fn workspace_panel_treats_yaml_null_attention_required_as_unblocked_planning() { fn workspace_panel_treats_yaml_null_attention_required_as_unblocked_planning() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -8,9 +8,9 @@ use ticket::config::{
ticket_config_scaffold, ticket_config_scaffold,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
TicketReview, TicketReviewResult, TicketWorkflowState, TicketRelationKind, TicketReview, TicketReviewResult, TicketWorkflowState,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -29,6 +29,7 @@ pub enum TicketCommand {
Review(ReviewOptions), Review(ReviewOptions),
State(StateOptions), State(StateOptions),
Close(CloseOptions), Close(CloseOptions),
Relation(RelationOptions),
Doctor, Doctor,
} }
@ -89,6 +90,25 @@ pub struct CloseOptions {
pub resolution: BodySource, pub resolution: BodySource,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationAction {
Add {
ticket: String,
kind: TicketRelationKind,
target: String,
note: Option<String>,
},
List {
ticket: Option<String>,
kind: Option<TicketRelationKind>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationOptions {
pub action: RelationAction,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum BodySource { pub enum BodySource {
Message(String), Message(String),
@ -163,6 +183,7 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
"review" => TicketCommand::Review(parse_review(&args[1..])?), "review" => TicketCommand::Review(parse_review(&args[1..])?),
"state" => TicketCommand::State(parse_state(&args[1..])?), "state" => TicketCommand::State(parse_state(&args[1..])?),
"close" => TicketCommand::Close(parse_close(&args[1..])?), "close" => TicketCommand::Close(parse_close(&args[1..])?),
"relation" => TicketCommand::Relation(parse_relation(&args[1..])?),
"doctor" => { "doctor" => {
if args.len() != 1 { if args.len() != 1 {
return Err(TicketCliError::new("ticket doctor takes no arguments")); return Err(TicketCliError::new("ticket doctor takes no arguments"));
@ -216,6 +237,7 @@ fn run_command(
TicketCommand::Review(options) => review(&backend, options), TicketCommand::Review(options) => review(&backend, options),
TicketCommand::State(options) => state(&backend, options), TicketCommand::State(options) => state(&backend, options),
TicketCommand::Close(options) => close(&backend, options), TicketCommand::Close(options) => close(&backend, options),
TicketCommand::Relation(options) => relation(&backend, options),
TicketCommand::Doctor => doctor(&backend), TicketCommand::Doctor => doctor(&backend),
TicketCommand::Init => unreachable!("init handled before backend setup"), TicketCommand::Init => unreachable!("init handled before backend setup"),
} }
@ -362,6 +384,56 @@ fn show(backend: &LocalTicketBackend, query: String) -> Result<TicketCliOutput,
} }
} }
if !ticket.relations.outgoing.is_empty()
|| !ticket.relations.incoming.is_empty()
|| !ticket.relations.blockers.is_empty()
|| !ticket.relations.notices.is_empty()
{
stdout.push_str("\n## relations\n\n");
if !ticket.relations.outgoing.is_empty() {
stdout.push_str("### outgoing\n\n");
for relation in &ticket.relations.outgoing {
stdout.push_str(&format!("- {} {}", relation.kind.as_str(), relation.target));
if let Some(note) = &relation.note {
stdout.push_str(&format!("{}", note.replace('\n', " ")));
}
stdout.push('\n');
}
}
if !ticket.relations.incoming.is_empty() {
stdout.push_str("### incoming / derived inverse\n\n");
for relation in &ticket.relations.incoming {
stdout.push_str(&format!(
"- {} {} (forward: {})",
relation.inverse_kind,
relation.source_ticket,
relation.forward_kind.as_str()
));
if let Some(note) = &relation.note {
stdout.push_str(&format!("{}", note.replace('\n', " ")));
}
stdout.push('\n');
}
}
if !ticket.relations.blockers.is_empty() {
stdout.push_str("### unresolved queue blockers\n\n");
for blocker in &ticket.relations.blockers {
stdout.push_str(&format!(
"- {} via {} (state: {})\n",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
));
}
}
if !ticket.relations.notices.is_empty() {
stdout.push_str("### notices\n\n");
for notice in &ticket.relations.notices {
stdout.push_str(&format!("- {}\n", notice.message));
}
}
}
if !ticket.artifacts.is_empty() { if !ticket.artifacts.is_empty() {
stdout.push_str("\n## artifacts\n\n"); stdout.push_str("\n## artifacts\n\n");
for artifact in &ticket.artifacts { for artifact in &ticket.artifacts {
@ -476,6 +548,53 @@ fn close(
Ok(success(format!("closed\t{}\n", options.query))) Ok(success(format!("closed\t{}\n", options.query)))
} }
fn relation(
backend: &LocalTicketBackend,
options: RelationOptions,
) -> Result<TicketCliOutput, TicketCliError> {
match options.action {
RelationAction::Add {
ticket,
kind,
target,
note,
} => {
let created = backend.add_ticket_relation(
TicketIdOrSlug::Query(ticket.clone()),
NewTicketRelation {
kind,
target: target.clone(),
note,
author: Some("yoi ticket".to_string()),
},
)?;
Ok(success(format!(
"relation\t{}\t{}\t{}\n",
created.ticket_id,
created.kind.as_str(),
created.target
)))
}
RelationAction::List { ticket, kind } => {
let ticket = ticket.map(TicketIdOrSlug::Query);
let relations = backend.query_ticket_relations(ticket, kind)?;
let mut stdout = String::from("ticket\tkind\ttarget\tauthor\tat\tnote\n");
for relation in relations {
stdout.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\n",
relation.ticket_id,
relation.kind.as_str(),
relation.target,
relation.author,
relation.at,
relation.note.unwrap_or_default().replace('\n', " ")
));
}
Ok(success(stdout))
}
}
}
fn doctor(backend: &LocalTicketBackend) -> Result<TicketCliOutput, TicketCliError> { fn doctor(backend: &LocalTicketBackend) -> Result<TicketCliOutput, TicketCliError> {
let report = backend.doctor()?; let report = backend.doctor()?;
let mut stdout = String::new(); let mut stdout = String::new();
@ -557,6 +676,93 @@ fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
Ok(ListOptions { state }) Ok(ListOptions { state })
} }
fn parse_relation(args: &[String]) -> Result<RelationOptions, TicketCliError> {
if args.is_empty() {
return Err(TicketCliError::new(
"ticket relation requires `add` or `list`",
));
}
match args[0].as_str() {
"add" => parse_relation_add(&args[1..]),
"list" => parse_relation_list(&args[1..]),
other => Err(TicketCliError::new(format!(
"unknown ticket relation action: {other}"
))),
}
}
fn parse_relation_add(args: &[String]) -> Result<RelationOptions, TicketCliError> {
let mut ticket = None;
let mut kind = None;
let mut target = None;
let mut note = None;
let mut i = 0;
while i < args.len() {
match option_with_value(args, &mut i)? {
Some(("--ticket", value)) => ticket = Some(value),
Some(("--kind", value)) => kind = Some(parse_relation_kind(&value)?),
Some(("--target", value)) => target = Some(value),
Some(("--note", value)) => note = Some(value),
Some((name, _)) => {
return Err(TicketCliError::new(format!(
"unknown relation add argument: {name}"
)));
}
None => {
return Err(TicketCliError::new(format!(
"unknown relation add argument: {}",
args[i]
)));
}
}
}
let ticket = ticket.ok_or_else(|| TicketCliError::new("relation add requires --ticket"))?;
let kind = kind.ok_or_else(|| TicketCliError::new("relation add requires --kind"))?;
let target = target.ok_or_else(|| TicketCliError::new("relation add requires --target"))?;
Ok(RelationOptions {
action: RelationAction::Add {
ticket,
kind,
target,
note,
},
})
}
fn parse_relation_list(args: &[String]) -> Result<RelationOptions, TicketCliError> {
let mut ticket = None;
let mut kind = None;
let mut i = 0;
while i < args.len() {
match option_with_value(args, &mut i)? {
Some(("--ticket", value)) => ticket = Some(value),
Some(("--kind", value)) => kind = Some(parse_relation_kind(&value)?),
Some((name, _)) => {
return Err(TicketCliError::new(format!(
"unknown relation list argument: {name}"
)));
}
None => {
return Err(TicketCliError::new(format!(
"unknown relation list argument: {}",
args[i]
)));
}
}
}
Ok(RelationOptions {
action: RelationAction::List { ticket, kind },
})
}
fn parse_relation_kind(value: &str) -> Result<TicketRelationKind, TicketCliError> {
TicketRelationKind::parse(value).ok_or_else(|| {
TicketCliError::new(format!(
"unknown relation kind `{value}`; expected depends_on, blocks, related, supersedes, or duplicate_of"
))
})
}
fn parse_comment(args: &[String]) -> Result<CommentOptions, TicketCliError> { fn parse_comment(args: &[String]) -> Result<CommentOptions, TicketCliError> {
if args.is_empty() || args[0].starts_with('-') { if args.is_empty() || args[0].starts_with('-') {
return Err(TicketCliError::new("comment requires <id>")); return Err(TicketCliError::new("comment requires <id>"));
@ -712,6 +918,10 @@ fn option_with_value(
"--file", "--file",
"--message", "--message",
"--resolution", "--resolution",
"--ticket",
"--kind",
"--target",
"--note",
] { ] {
if arg == name { if arg == name {
let value = args let value = args
@ -811,7 +1021,7 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n"
} }
#[cfg(test)] #[cfg(test)]
@ -1011,6 +1221,58 @@ mod tests {
); );
} }
#[test]
fn ticket_cli_records_lists_and_shows_relations() {
let temp = TempDir::new().unwrap();
let source = created_id(&run(&temp, &["create", "--title", "Relation Source"]));
let target = created_id(&run(&temp, &["create", "--title", "Relation Target"]));
let added = run(
&temp,
&[
"relation",
"add",
"--ticket",
&source,
"--kind",
"depends_on",
"--target",
&target,
"--note",
"target first",
],
);
assert_eq!(
added.stdout,
format!("relation\t{source}\tdepends_on\t{target}\n")
);
let listed = run(&temp, &["relation", "list", "--ticket", &target]);
assert!(listed.stdout.contains("ticket\tkind\ttarget"));
assert!(
listed
.stdout
.contains(&format!("{source}\tdepends_on\t{target}"))
);
let shown_source = run(&temp, &["show", &source]);
assert!(shown_source.stdout.contains("## relations"));
assert!(
shown_source
.stdout
.contains(&format!("- depends_on {target}"))
);
assert!(shown_source.stdout.contains("unresolved queue blockers"));
let shown_target = run(&temp, &["show", &target]);
assert!(shown_target.stdout.contains("incoming / derived inverse"));
assert!(
shown_target
.stdout
.contains(&format!("dependency_of {source}"))
);
}
#[test] #[test]
fn ticket_cli_uses_configured_backend_root() { fn ticket_cli_uses_configured_backend_root() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -14,6 +14,7 @@ Do not treat ad-hoc chat summaries, memory records, or Pod notifications as the
- `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.
- `LocalTicketBackend`: the current `.yoi/tickets/` markdown/thread/artifacts storage backend. - `LocalTicketBackend`: the current `.yoi/tickets/` markdown/thread/artifacts storage backend.
- `Ticket relation`: durable project-level Ticket-to-Ticket metadata stored as forward canonical-id relations (`depends_on`, `blocks`, `related`, `supersedes`, `duplicate_of`). Inverse views such as `blocked_by` are derived, not stored.
A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms. A Ticket may represent a feature, bug, cleanup, design decision, investigation, workflow change, release task, or orchestration task. The common requirement is that the Ticket is a concrete work item that can be implemented, reviewed, validated, and closed on its own terms.
@ -38,10 +39,14 @@ Pods with the Ticket built-in feature can use typed Ticket tools:
- `TicketReview` - `TicketReview`
- `TicketWorkflowState` - `TicketWorkflowState`
- `TicketClose` - `TicketClose`
- `TicketRelationRecord`
- `TicketRelationQuery`
- `TicketDoctor` - `TicketDoctor`
These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`. These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
Relation tools are for non-hierarchical project metadata only. Use canonical opaque Ticket ids, store forward relations only, and keep runtime execution planning (capacity, ordering decisions, do-not-parallelize notes, Pod/session/worktree ownership) in OrchestrationPlan or session-local records instead of relation metadata. Unresolved `depends_on` and incoming unresolved `blocks` are queue/acceptance blockers; `related` is not blocking, and `supersedes` / `duplicate_of` are diagnostics rather than automatic lifecycle transitions.
Use them when a Pod needs to materialize or update project records: Use them when a Pod needs to materialize or update project records:
- Intake creates a new Ticket after user agreement. - Intake creates a new Ticket after user agreement.