diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 42206d40..064c6684 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -35,6 +35,8 @@ const REQUIRED_FIELDS: [&str; 11] = [ "assignee", "legacy_ticket", ]; +const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; +const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; pub type Result = std::result::Result; @@ -211,6 +213,8 @@ pub enum TicketEventKind { Decision, ImplementationReport, Review, + StateChanged, + IntakeSummary, StatusChanged, Close, Other(String), @@ -225,6 +229,8 @@ impl TicketEventKind { Self::Decision => "decision", Self::ImplementationReport => "implementation_report", Self::Review => "review", + Self::StateChanged => "state_changed", + Self::IntakeSummary => "intake_summary", Self::StatusChanged => "status_changed", Self::Close => "close", Self::Other(value) => value.as_str(), @@ -239,6 +245,8 @@ impl TicketEventKind { Self::Decision => "Decision".to_string(), Self::ImplementationReport => "Implementation report".to_string(), Self::Review => "Review".to_string(), + Self::StateChanged => "State changed".to_string(), + Self::IntakeSummary => "Intake summary".to_string(), Self::StatusChanged => "Status changed".to_string(), Self::Close => "Closed".to_string(), Self::Other(value) => value.clone(), @@ -255,6 +263,8 @@ impl From<&str> for TicketEventKind { "decision" => Self::Decision, "implementation_report" => Self::ImplementationReport, "review" => Self::Review, + "state_changed" => Self::StateChanged, + "intake_summary" => Self::IntakeSummary, "status_changed" => Self::StatusChanged, "close" | "closed" => Self::Close, other => Self::Other(other.to_string()), @@ -322,6 +332,51 @@ impl NewTicketEvent { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketStateChange { + pub from: String, + pub to: String, + pub author: Option, + pub reason: String, + pub body: MarkdownText, + pub references: Vec, +} + +impl TicketStateChange { + pub fn new( + from: impl Into, + to: impl Into, + reason: impl Into, + body: impl Into, + ) -> Self { + Self { + from: from.into(), + to: to.into(), + author: None, + reason: reason.into(), + body: body.into(), + references: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketIntakeSummary { + pub author: Option, + pub body: MarkdownText, + pub references: Vec, +} + +impl TicketIntakeSummary { + pub fn new(body: impl Into) -> Self { + Self { + author: None, + body: body.into(), + references: Vec::new(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketReview { pub result: TicketReviewResult, @@ -457,9 +512,14 @@ pub struct TicketEvent { pub author: Option, pub at: Option, pub status: Option, + pub from: Option, + pub to: Option, + pub reason: Option, + pub state_field: Option, pub heading: Option, pub body: MarkdownText, pub references: Vec, + pub attributes: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -529,6 +589,14 @@ pub trait TicketBackend { fn show(&self, id: TicketIdOrSlug) -> Result; fn create(&self, input: NewTicket) -> Result; fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; + fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; + fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>; + fn set_state_field( + &self, + id: TicketIdOrSlug, + field: &str, + change: TicketStateChange, + ) -> Result<()>; fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; @@ -668,9 +736,18 @@ impl LocalTicketBackend { heading: &str, author: &str, status: Option<&str>, + attrs: &[(&str, &str)], body: &MarkdownText, ) -> Result<()> { let at = now_utc(); + let mut event_attrs = vec![("event", event), ("author", author), ("at", at.as_str())]; + if let Some(status) = status { + event_attrs.push(("status", status)); + } + event_attrs.extend_from_slice(attrs); + let comment = render_event_comment(&event_attrs)?; + let entry = format!("\n{comment}\n\n## {heading}\n\n{}\n\n---\n", body.as_str()); + let thread = dir.join("thread.md"); if !thread.exists() { File::create(&thread).map_err(|e| io_err(&thread, e))?; @@ -679,17 +756,53 @@ impl LocalTicketBackend { .append(true) .open(&thread) .map_err(|e| io_err(&thread, e))?; - write!(file, "\n\n\n## {heading}\n\n{}\n\n---\n", body.as_str()) + file.write_all(entry.as_bytes()) .map_err(|e| io_err(&thread, e))?; file.sync_data().map_err(|e| io_err(&thread, e))?; self.set_frontmatter_fields(&dir.join("item.md"), &[("updated_at", at.as_str())]) } + fn append_state_changed_event( + &self, + dir: &Path, + change: &TicketStateChange, + state_field: Option<&str>, + ) -> Result<()> { + validate_state_change(change)?; + let author = change.author.clone().unwrap_or_else(default_author); + let mut attrs = vec![ + ("from", change.from.as_str()), + ("to", change.to.as_str()), + ("reason", change.reason.as_str()), + ]; + if let Some(state_field) = state_field { + attrs.push(("field", state_field)); + } + self.append_thread_event( + dir, + TicketEventKind::StateChanged.as_str(), + &TicketEventKind::StateChanged.heading(), + &author, + None, + &attrs, + &change.body, + ) + } + + fn append_intake_summary_event(&self, dir: &Path, summary: &TicketIntakeSummary) -> Result<()> { + validate_intake_summary(summary)?; + let author = summary.author.clone().unwrap_or_else(default_author); + self.append_thread_event( + dir, + TicketEventKind::IntakeSummary.as_str(), + &TicketEventKind::IntakeSummary.heading(), + &author, + None, + &[], + &summary.body, + ) + } + fn set_frontmatter_fields(&self, item: &Path, updates: &[(&str, &str)]) -> Result<()> { let content = fs::read_to_string(item).map_err(|e| io_err(item, e))?; let updated = replace_frontmatter_fields(&content, updates).map_err(|message| { @@ -765,9 +878,18 @@ impl TicketBackend for LocalTicketBackend { ))); } ensure_child_of(&self.root, &dir)?; + let created = now_utc(); + let author = input + .author + .unwrap_or_else(|| "LocalTicketBackend".to_string()); + let create_comment = render_event_comment(&[ + ("event", TicketEventKind::Create.as_str()), + ("author", &author), + ("at", &created), + ])?; + fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?; atomic_write(&dir.join("artifacts/.gitkeep"), b"")?; - let created = now_utc(); let mut fields = Vec::new(); fields.push(("id".to_string(), id.clone())); fields.push(("slug".to_string(), slug.clone())); @@ -800,11 +922,8 @@ impl TicketBackend for LocalTicketBackend { } let item = serialize_item(&fields, input.body.as_str()); atomic_write(&dir.join("item.md"), item.as_bytes())?; - let author = input - .author - .unwrap_or_else(|| "LocalTicketBackend".to_string()); let thread = format!( - "\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n" + "{create_comment}\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n" ); atomic_write(&dir.join("thread.md"), thread.as_bytes())?; Ok(TicketRef { @@ -824,10 +943,49 @@ impl TicketBackend for LocalTicketBackend { &event.kind.heading(), &author, None, + &[], &event.body, ) } + fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + self.append_state_changed_event(&dir, &change, None) + } + + fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()> { + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + self.append_intake_summary_event(&dir, &summary) + } + + fn set_state_field( + &self, + id: TicketIdOrSlug, + field: &str, + change: TicketStateChange, + ) -> Result<()> { + validate_state_field_name(field)?; + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let parsed = read_item_file(&item)?; + let current = parsed + .frontmatter + .get(field) + .map(String::as_str) + .unwrap_or(""); + if current != change.from.as_str() { + return Err(TicketError::Conflict(format!( + "state field `{field}` changed concurrently: expected `{}`, found `{current}`", + change.from + ))); + } + self.append_state_changed_event(&dir, &change, Some(field))?; + self.set_frontmatter_fields(&item, &[(field, change.to.as_str())]) + } + fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> { let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; @@ -838,6 +996,7 @@ impl TicketBackend for LocalTicketBackend { &review.result.heading(), &author, Some(review.result.as_str()), + &[], &review.body, ) } @@ -873,6 +1032,7 @@ impl TicketBackend for LocalTicketBackend { "Status changed", &author, Some(status.as_str()), + &[], &body, ) } @@ -915,6 +1075,7 @@ impl TicketBackend for LocalTicketBackend { "Closed", &author, Some("closed"), + &[], &resolution, ) } @@ -1270,6 +1431,114 @@ fn replace_frontmatter_fields( Ok(out) } +fn render_event_comment(attrs: &[(&str, &str)]) -> Result { + let mut out = String::from(""); + Ok(out) +} + +fn format_event_attr_value(value: &str) -> String { + if !value.is_empty() + && !value.chars().any(char::is_whitespace) + && !value.contains('"') + && !value.contains('\\') + && !value.contains("-->") + { + return value.to_string(); + } + let mut out = String::from("\""); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + _ => out.push(ch), + } + } + out.push('"'); + out +} + +fn validate_event_attr(key: &str, value: &str) -> Result<()> { + if key.trim().is_empty() || key.chars().any(char::is_whitespace) || key.contains(':') { + return Err(TicketError::Conflict(format!( + "thread event attribute key is invalid: {key:?}" + ))); + } + if value.contains('\n') || value.contains('\r') || value.contains("-->") { + return Err(TicketError::Conflict(format!( + "thread event attribute `{key}` must be a single safe comment value" + ))); + } + Ok(()) +} + +fn validate_required_event_value(label: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(TicketError::Conflict(format!( + "state_changed event requires non-empty {label}" + ))); + } + validate_event_attr(label, value) +} + +fn validate_state_change(change: &TicketStateChange) -> Result<()> { + validate_required_event_value("from", &change.from)?; + validate_required_event_value("to", &change.to)?; + validate_required_event_value("reason", &change.reason)?; + if change.reason.len() > MAX_STATE_CHANGE_REASON_BYTES { + return Err(TicketError::Conflict(format!( + "state_changed reason exceeds {MAX_STATE_CHANGE_REASON_BYTES} bytes" + ))); + } + if let Some(author) = change.author.as_deref() { + validate_required_event_value("author", author)?; + } + if change.body.as_str().len() > MAX_INTAKE_SUMMARY_BODY_BYTES { + return Err(TicketError::Conflict(format!( + "state_changed body exceeds {MAX_INTAKE_SUMMARY_BODY_BYTES} bytes" + ))); + } + Ok(()) +} + +fn validate_intake_summary(summary: &TicketIntakeSummary) -> Result<()> { + let body = summary.body.as_str(); + if body.trim().is_empty() { + return Err(TicketError::Conflict( + "intake_summary event requires a non-empty body".to_string(), + )); + } + if body.len() > MAX_INTAKE_SUMMARY_BODY_BYTES { + return Err(TicketError::Conflict(format!( + "intake_summary body exceeds {MAX_INTAKE_SUMMARY_BODY_BYTES} bytes" + ))); + } + if let Some(author) = summary.author.as_deref() { + validate_required_event_value("author", author)?; + } + Ok(()) +} + +fn validate_state_field_name(field: &str) -> Result<()> { + if field.trim().is_empty() + || field.chars().any(char::is_whitespace) + || field.contains(':') + || field.contains("--") + { + return Err(TicketError::Conflict(format!( + "state field name is invalid: {field:?}" + ))); + } + Ok(()) +} + fn parse_thread(path: &Path) -> Result> { let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; let mut events = Vec::new(); @@ -1318,9 +1587,14 @@ fn parse_thread(path: &Path) -> Result> { author: attrs.get("author").cloned(), at: attrs.get("at").cloned(), status: attrs.get("status").cloned(), + from: attrs.get("from").cloned(), + to: attrs.get("to").cloned(), + reason: attrs.get("reason").cloned(), + state_field: attrs.get("field").cloned(), heading, body: MarkdownText::new(body), references: Vec::new(), + attributes: attrs, }); } else { idx += 1; @@ -1330,16 +1604,69 @@ fn parse_thread(path: &Path) -> Result> { } fn parse_event_comment(comment: &str) -> BTreeMap { - // Thread event comments use unquoted `key: value` pairs separated by spaces. - // Values currently do not contain spaces; this parser intentionally preserves - // the local file format instead of treating thread.md as strict YAML. let mut attrs = BTreeMap::new(); - let mut iter = comment.split_whitespace().peekable(); - while let Some(token) = iter.next() { - if let Some(key) = token.strip_suffix(':') { - if let Some(value) = iter.next() { - attrs.insert(key.to_string(), value.to_string()); + let mut chars = comment.char_indices().peekable(); + while let Some((_, ch)) = chars.peek().copied() { + if ch.is_whitespace() { + chars.next(); + continue; + } + let start = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); + while let Some((_, ch)) = chars.peek().copied() { + if ch == ':' || ch.is_whitespace() { + break; } + chars.next(); + } + let end = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); + if chars.peek().map(|(_, ch)| *ch) != Some(':') { + while let Some((_, ch)) = chars.peek().copied() { + if ch.is_whitespace() { + break; + } + chars.next(); + } + continue; + } + chars.next(); + while let Some((_, ch)) = chars.peek().copied() { + if ch.is_whitespace() { + chars.next(); + } else { + break; + } + } + let value = if chars.peek().map(|(_, ch)| *ch) == Some('"') { + chars.next(); + let mut value = String::new(); + let mut escaped = false; + for (_, ch) in chars.by_ref() { + if escaped { + value.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + break; + } else { + value.push(ch); + } + } + value + } else { + let value_start = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); + while let Some((_, ch)) = chars.peek().copied() { + if ch.is_whitespace() { + break; + } + chars.next(); + } + let value_end = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len()); + comment[value_start..value_end].to_string() + }; + let key = &comment[start..end]; + if !key.is_empty() { + attrs.insert(key.to_string(), value); } } attrs @@ -1347,6 +1674,7 @@ fn parse_event_comment(comment: &str) -> BTreeMap { fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result<()> { let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; + let mut intake_summary_lines = Vec::new(); for (line_no, line) in content.lines().enumerate() { let trimmed = line.trim(); if trimmed.starts_with("") { @@ -1364,7 +1692,13 @@ fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result< .and_then(|v| v.strip_suffix(" -->")) { let attrs = parse_event_comment(comment); - if attrs.contains_key("event") && attrs.get("at").is_none() { + let Some(event) = attrs.get("event").map(String::as_str) else { + continue; + }; + if attrs + .get("at") + .map_or(true, |value| value.trim().is_empty()) + { report.push_error( format!( "thread event missing at: {}:{}", @@ -1374,8 +1708,8 @@ fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result< Some(path.to_path_buf()), ); } - if attrs.get("event").map(String::as_str) == Some("review") { - match attrs.get("status").map(String::as_str) { + match event { + "review" => match attrs.get("status").map(String::as_str) { Some("approve" | "request_changes") => {} _ => report.push_warning( format!( @@ -1385,7 +1719,56 @@ fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result< ), Some(path.to_path_buf()), ), + }, + "state_changed" => { + for key in ["from", "to", "reason", "author"] { + if attrs.get(key).map_or(true, |value| value.trim().is_empty()) { + report.push_error( + format!( + "state_changed event missing {key}: {}:{}", + path.display(), + line_no + 1 + ), + Some(path.to_path_buf()), + ); + } + } } + "intake_summary" => { + if attrs + .get("author") + .map_or(true, |value| value.trim().is_empty()) + { + report.push_error( + format!( + "intake_summary event missing author: {}:{}", + path.display(), + line_no + 1 + ), + Some(path.to_path_buf()), + ); + } + intake_summary_lines.push(line_no + 1); + } + _ => {} + } + } + } + if !intake_summary_lines.is_empty() { + let summaries = parse_thread(path)? + .into_iter() + .filter(|event| event.kind == TicketEventKind::IntakeSummary); + for (idx, event) in summaries.enumerate() { + if event.body.as_str().trim().is_empty() { + let line = intake_summary_lines.get(idx).copied().unwrap_or_default(); + report.push_error( + format!( + "intake_summary event missing body at {}:{}", + path.display(), + line + ), + Some(path.to_path_buf()), + ); } } } @@ -1618,6 +2001,202 @@ action_required: none assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn invalid_thread_event_attributes_do_not_modify_thread() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend + .create(NewTicket::new("Append Safety Ticket")) + .unwrap(); + let thread_path = tmp + .path() + .join("tickets/open") + .join(&ticket.id) + .join("thread.md"); + let original = fs::read_to_string(&thread_path).unwrap(); + + let mut comment = NewTicketEvent::new(TicketEventKind::Comment, "This must not append."); + comment.author = Some("bad\nauthor".into()); + assert!(matches!( + backend.add_event(TicketIdOrSlug::Id(ticket.id.clone()), comment), + Err(TicketError::Conflict(_)) + )); + assert_eq!(fs::read_to_string(&thread_path).unwrap(), original); + + let mut review = TicketReview::approve("This must not append either."); + review.author = Some("bad-->author".into()); + assert!(matches!( + backend.review(TicketIdOrSlug::Id(ticket.id.clone()), review), + Err(TicketError::Conflict(_)) + )); + assert_eq!(fs::read_to_string(&thread_path).unwrap(), original); + + let invalid_kind = NewTicketEvent::new( + TicketEventKind::Other("bad\nevent".into()), + "Invalid event kind.", + ); + assert!(matches!( + backend.add_event(TicketIdOrSlug::Id(ticket.id.clone()), invalid_kind), + Err(TicketError::Conflict(_)) + )); + assert_eq!(fs::read_to_string(&thread_path).unwrap(), original); + } + + #[test] + fn create_rejects_invalid_author_before_writing_ticket_record() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut input = NewTicket::new("Invalid Author Ticket"); + input.slug = Some("invalid-author-ticket".into()); + input.author = Some("bad-->author".into()); + + assert!(matches!( + backend.create(input), + Err(TicketError::Conflict(_)) + )); + let open_dir = tmp.path().join("tickets/open"); + let entries = fs::read_dir(open_dir).unwrap().count(); + assert_eq!(entries, 0); + } + + #[test] + fn state_changed_and_intake_summary_events_round_trip() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend + .create(NewTicket::new("Typed Thread Ticket")) + .unwrap(); + let mut change = TicketStateChange::new( + "preflight", + "implementation-ready", + "preflight approved", + "Preflight finished; implementation can begin.", + ); + change.author = Some("orchestrator".into()); + backend + .add_state_changed(TicketIdOrSlug::Id(ticket.id.clone()), change) + .unwrap(); + let mut summary = TicketIntakeSummary::new("## Accepted intent\n\nImplement typed events."); + summary.author = Some("intake".into()); + backend + .add_intake_summary(TicketIdOrSlug::Id(ticket.id.clone()), summary) + .unwrap(); + + let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); + let state_event = record + .events + .iter() + .find(|event| event.kind == TicketEventKind::StateChanged) + .unwrap(); + assert_eq!(state_event.from.as_deref(), Some("preflight")); + assert_eq!(state_event.to.as_deref(), Some("implementation-ready")); + assert_eq!(state_event.reason.as_deref(), Some("preflight approved")); + assert_eq!(state_event.author.as_deref(), Some("orchestrator")); + assert_eq!( + state_event.attributes.get("reason").map(String::as_str), + Some("preflight approved") + ); + assert!( + record + .events + .iter() + .any(|event| event.kind == TicketEventKind::IntakeSummary + && event.body.as_str().contains("Accepted intent")) + ); + let thread = fs::read_to_string( + tmp.path() + .join("tickets/open") + .join(&ticket.id) + .join("thread.md"), + ) + .unwrap(); + assert!(thread.contains("event: state_changed")); + assert!(thread.contains("reason: \"preflight approved\"")); + assert!(thread.contains("event: intake_summary")); + let report = backend.doctor().unwrap(); + assert!(report.is_ok(), "{:?}", report.diagnostics); + } + + #[test] + fn set_state_field_updates_frontmatter_and_appends_transition() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let ticket = backend + .create(NewTicket::new("State Field Ticket")) + .unwrap(); + let item = tmp + .path() + .join("tickets/open") + .join(&ticket.id) + .join("item.md"); + backend + .set_frontmatter_fields(&item, &[("readiness", "preflight")]) + .unwrap(); + + let mut change = TicketStateChange::new( + "preflight", + "implementation-ready", + "requirements accepted", + "Implementation is authorized.", + ); + change.author = Some("orchestrator".into()); + backend + .set_state_field(TicketIdOrSlug::Id(ticket.id.clone()), "readiness", change) + .unwrap(); + + let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); + assert_eq!( + record.meta.readiness.as_deref(), + Some("implementation-ready") + ); + let event = record + .events + .iter() + .find(|event| event.kind == TicketEventKind::StateChanged) + .unwrap(); + assert_eq!(event.state_field.as_deref(), Some("readiness")); + let stale = TicketStateChange::new( + "preflight", + "done", + "stale update", + "This must be rejected.", + ); + assert!(matches!( + backend.set_state_field(TicketIdOrSlug::Id(ticket.id), "readiness", stale), + Err(TicketError::Conflict(_)) + )); + } + + #[test] + fn doctor_validates_typed_thread_event_attributes() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("tickets"); + fs::create_dir_all(root.join("open/bad/artifacts")).unwrap(); + fs::write( + root.join("open/bad/item.md"), + "---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n", + ) + .unwrap(); + fs::write( + root.join("open/bad/thread.md"), + "\n\n## State changed\n\n---\n\n\n\n## Intake summary\n\n---\n", + ) + .unwrap(); + fs::create_dir_all(root.join("pending")).unwrap(); + fs::create_dir_all(root.join("closed")).unwrap(); + let report = LocalTicketBackend::new(&root).doctor().unwrap(); + let messages = report + .diagnostics + .iter() + .map(|d| d.message.as_str()) + .collect::>() + .join("\n"); + assert!(!report.is_ok()); + assert!(messages.contains("state_changed event missing to")); + assert!(messages.contains("state_changed event missing reason")); + assert!(messages.contains("intake_summary event missing body")); + } + #[test] fn doctor_reports_core_consistency_errors() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 7de266b8..e754e220 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -607,6 +607,11 @@ fn ticket_json( "author": event.author, "at": event.at, "status": event.status, + "from": event.from, + "to": event.to, + "reason": event.reason, + "state_field": event.state_field, + "attributes": event.attributes, "heading": event.heading, "body": truncate_text(event.body.as_str(), body_max_bytes), }) diff --git a/docs/development/work-items.md b/docs/development/work-items.md index e91edc42..c5bd29d8 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -353,7 +353,7 @@ The current LocalTicketBackend stores records under: resolution.md # closed Tickets only ``` -Backend integrations must preserve this format until an explicit migration changes it. The repository-root `work-items/` path is no longer a live mutable backend; do not recreate it for Ticket records. Human users should prefer `yoi panel`, Ticket tools, or `yoi ticket ...` when working directly with repository records. +Backend integrations must preserve this format until an explicit migration changes it. `thread.md` is an append-only typed event log: existing events such as `create`, `comment`, `plan`, `decision`, `implementation_report`, `review`, `status_changed`, and `close` remain valid, while `state_changed` records durable transition metadata (`from`, `to`, `reason`, optional `field`, plus `author` and `at`) and `intake_summary` records the bounded Intake outcome body. Thread events are audit history, not current-state authority; current state belongs in `item.md` frontmatter or the owning backend record. The repository-root `work-items/` path is no longer a live mutable backend; do not recreate it for Ticket records. Human users should prefer `yoi panel`, Ticket tools, or `yoi ticket ...` when working directly with repository records. ## Validation