diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..e27b6edc766 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -729,6 +729,141 @@ pub async fn cmd_send_message( Ok(()) } +pub struct WorkReportParams { + pub channel_id: String, + pub thread_root: String, + pub status: crate::ReportStatus, + pub outcome: String, + pub deliverables: Vec, + pub decisions: Vec, + pub verification: Vec, + pub risks: Vec, + pub next_actions: Vec, + pub prior: Option, +} + +pub async fn cmd_publish_work_report( + client: &BuzzClient, + params: WorkReportParams, +) -> Result<(), CliError> { + let channel_id = parse_uuid(¶ms.channel_id)?; + let root_event = fetch_event(client, ¶ms.thread_root).await?; + let root_channel = channel_id_from_event(¶ms.thread_root, &root_event)?; + if root_channel != channel_id { + return Err(CliError::Usage(format!( + "thread root {} does not belong to channel {}", + params.thread_root, params.channel_id + ))); + } + let resolved_root = thread_ref_from_event(¶ms.thread_root, &root_event)? + .root_event_id + .to_hex(); + if !resolved_root.eq_ignore_ascii_case(¶ms.thread_root) { + return Err(CliError::Usage(format!( + "--thread must reference the thread root (use {resolved_root})" + ))); + } + let thread_root = parse_event_id(¶ms.thread_root)?; + let head_filter = serde_json::json!({ + "kinds": [40009], + "#h": [params.channel_id.as_str()], + "#e": [params.thread_root.as_str()], + "limit": 100 + }); + let mut report_events = fetch_events(client, &head_filter) + .await + .ok_or_else(|| CliError::Other("could not load the current work-report head".into()))?; + report_events.retain(|event| { + event + .get("tags") + .and_then(serde_json::Value::as_array) + .is_some_and(|tags| { + tags.iter().any(|tag| { + let Some(parts) = tag.as_array() else { + return false; + }; + parts.first().and_then(serde_json::Value::as_str) == Some("e") + && parts.get(1).and_then(serde_json::Value::as_str) + == Some(params.thread_root.as_str()) + && parts.get(3).and_then(serde_json::Value::as_str) == Some("root") + }) + }) + }); + report_events.sort_by(|left, right| { + left.get("created_at") + .and_then(serde_json::Value::as_u64) + .cmp(&right.get("created_at").and_then(serde_json::Value::as_u64)) + .then_with(|| { + left.get("id") + .and_then(serde_json::Value::as_str) + .cmp(&right.get("id").and_then(serde_json::Value::as_str)) + }) + }); + let current_head = report_events + .last() + .and_then(|event| event.get("id")) + .and_then(serde_json::Value::as_str); + match (current_head, params.prior.as_deref()) { + (Some(head), Some(prior)) if head.eq_ignore_ascii_case(prior) => {} + (Some(head), _) => { + return Err(CliError::Conflict(format!( + "work report head changed; retry with --prior {head}" + ))) + } + (None, Some(_)) => { + return Err(CliError::Conflict( + "cannot set --prior because this thread has no work report".into(), + )) + } + (None, None) => {} + } + let prior = match params.prior.as_deref() { + Some(prior_id) => { + let prior_event = fetch_event(client, prior_id).await?; + if prior_event.get("kind").and_then(serde_json::Value::as_u64) != Some(40009) { + return Err(CliError::Usage( + "--prior must reference a work report".into(), + )); + } + let tags = prior_event + .get("tags") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| CliError::Usage("prior work report has no tags".into()))?; + let matches_root = tags.iter().any(|tag| { + let Some(parts) = tag.as_array() else { + return false; + }; + parts.first().and_then(serde_json::Value::as_str) == Some("e") + && parts.get(1).and_then(serde_json::Value::as_str) + == Some(params.thread_root.as_str()) + && parts.get(3).and_then(serde_json::Value::as_str) == Some("root") + }); + if !matches_root || channel_id_from_event(prior_id, &prior_event)? != channel_id { + return Err(CliError::Usage( + "--prior must reference a work report for the same thread".into(), + )); + } + Some(parse_event_id(prior_id)?) + } + None => None, + }; + let report = buzz_sdk::WorkReport { + status: params.status.into(), + outcome: params.outcome, + deliverables: params.deliverables, + decisions: params.decisions, + verification: params.verification, + risks: params.risks, + next_actions: params.next_actions, + }; + let builder = buzz_sdk::build_work_report(channel_id, thread_root, prior, &report) + .map_err(|error| CliError::Usage(error.to_string()))?; + let event = client.sign_event(builder)?; + let response = client.submit_event(event).await?; + println!("{}", normalize_write_response(&response)); + Ok(()) +} + pub struct SendDiffParams { pub channel_id: String, pub diff: String, @@ -965,6 +1100,35 @@ pub async fn dispatch( ) .await } + MessagesCmd::Report { + channel, + thread, + status, + outcome, + deliverables, + decisions, + verification, + risks, + next_actions, + prior, + } => { + cmd_publish_work_report( + client, + WorkReportParams { + channel_id: channel, + thread_root: thread, + status, + outcome, + deliverables, + decisions, + verification, + risks, + next_actions, + prior, + }, + ) + .await + } MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await, MessagesCmd::Delete { event, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa2..ae4cf8abc12 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -142,6 +142,32 @@ pub enum PresenceStatus { Offline, } +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum ReportStatus { + #[value(name = "completed")] + Completed, + #[value(name = "in-review")] + InReview, + #[value(name = "needs-decision")] + NeedsDecision, + #[value(name = "blocked")] + Blocked, + #[value(name = "failed")] + Failed, +} + +impl From for buzz_sdk::WorkReportStatus { + fn from(value: ReportStatus) -> Self { + match value { + ReportStatus::Completed => Self::Completed, + ReportStatus::InReview => Self::InReview, + ReportStatus::NeedsDecision => Self::NeedsDecision, + ReportStatus::Blocked => Self::Blocked, + ReportStatus::Failed => Self::Failed, + } + } +} + #[derive(Clone, clap::ValueEnum)] pub enum EmojiScope { #[value(name = "own")] @@ -435,6 +461,42 @@ pub enum MessagesCmd { #[arg(long)] reply_to: Option, }, + /// Publish or update the structured outcome report for a thread + #[command( + after_help = "Example:\n buzz messages report --channel --thread --status completed --outcome \"Shipped the fix\" --deliverable --verification \"CI passed\"" + )] + Report { + /// Channel UUID containing the thread + #[arg(long)] + channel: String, + /// Thread root event ID + #[arg(long)] + thread: String, + /// Current report status + #[arg(long, value_enum)] + status: ReportStatus, + /// One-sentence result or required action + #[arg(long)] + outcome: String, + /// PR, file, document, or artifact reference (repeatable) + #[arg(long = "deliverable")] + deliverables: Vec, + /// Material decision and rationale (repeatable) + #[arg(long = "decision")] + decisions: Vec, + /// Test, CI, runtime check, or other evidence (repeatable) + #[arg(long = "verification")] + verification: Vec, + /// Material risk or limitation (repeatable) + #[arg(long = "risk")] + risks: Vec, + /// Next action with its owner (repeatable) + #[arg(long = "next-action")] + next_actions: Vec, + /// Previous work-report event ID when updating + #[arg(long)] + prior: Option, + }, /// Edit a previously sent message Edit { /// Event ID of the message to edit (64-char hex) @@ -2302,6 +2364,7 @@ mod tests { "delete", "edit", "get", + "report", "search", "send", "send-diff", @@ -2429,6 +2492,46 @@ mod tests { ); } + #[test] + fn work_report_command_accepts_structured_repeatable_fields() { + let channel = uuid::Uuid::new_v4().to_string(); + let root = "ab".repeat(32); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "report", + "--channel", + channel.as_str(), + "--thread", + root.as_str(), + "--status", + "in-review", + "--outcome", + "Ready for review", + "--deliverable", + "https://example.com/pr/1", + "--verification", + "CI passed", + "--next-action", + "Maintainer: review", + ]) + .is_ok()); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "report", + "--channel", + channel.as_str(), + "--thread", + root.as_str(), + "--status", + "done", + "--outcome", + "Invalid status", + ]) + .is_err()); + } + #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ @@ -2440,7 +2543,7 @@ mod tests { ("feed", 1), ("issues", 6), ("media", 1), - ("messages", 8), + ("messages", 9), ("pack", 2), ("patches", 4), ("pr", 5), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..3f72e524777 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -491,6 +491,8 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; pub const KIND_STREAM_REMINDER: u32 = 40007; /// A diff/patch message showing file changes (unified diff format). pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; +/// A structured, signed outcome report rooted in a channel thread. +pub const KIND_WORK_REPORT: u32 = 40009; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -707,6 +709,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, + KIND_WORK_REPORT, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..6771f9f302f 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1172,6 +1172,7 @@ mod tests { use buzz_core::kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_WORK_REPORT, }; use buzz_core::observer::{ encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1223,6 +1224,7 @@ mod tests { for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_WORK_REPORT, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index dd2fa6e93e0..94482d14c13 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -33,7 +33,7 @@ use buzz_core::kind::{ KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + KIND_WORK_REPORT, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; @@ -479,6 +479,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), @@ -712,6 +713,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_WORK_REPORT | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE @@ -1326,6 +1328,101 @@ fn validate_diff_event(event: &Event) -> Result<(), String> { Ok(()) } +/// Validate the signed envelope and structured JSON body of kind:40009. +fn validate_work_report_event(event: &Event) -> Result<(), String> { + let is_hex64 = |value: &str| { + value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) + }; + if event.content.len() > 32 * 1024 { + return Err("work report content exceeds 32KB limit".to_string()); + } + let body: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|_| "work report content must be a JSON object".to_string())?; + let object = body + .as_object() + .ok_or_else(|| "work report content must be a JSON object".to_string())?; + let status = object + .get("status") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "work report requires a status".to_string())?; + if !matches!( + status, + "completed" | "in_review" | "needs_decision" | "blocked" | "failed" + ) { + return Err("work report status is invalid".to_string()); + } + let outcome = object + .get("outcome") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 1_024) + .ok_or_else(|| "work report outcome must be 1..=1024 bytes".to_string())?; + let _ = outcome; + for field in [ + "deliverables", + "decisions", + "verification", + "risks", + "next_actions", + ] { + let Some(value) = object.get(field) else { + continue; + }; + let values = value + .as_array() + .ok_or_else(|| format!("work report {field} must be an array"))?; + if values.len() > 20 + || values.iter().any(|value| { + value + .as_str() + .map(str::trim) + .is_none_or(|value| value.is_empty() || value.len() > 2_048) + }) + { + return Err(format!( + "work report {field} must contain at most 20 non-empty strings of at most 2048 bytes" + )); + } + } + + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let values = |name: &str| { + tags.iter() + .filter(|tag| tag.first().map(String::as_str) == Some(name)) + .collect::>() + }; + let roots = values("e"); + if roots.len() != 1 + || roots[0].len() < 4 + || !roots[0][2].is_empty() + || roots[0][3] != "root" + || !is_hex64(&roots[0][1]) + { + return Err("work report requires exactly one root-marked e tag".to_string()); + } + let type_tags = values("t"); + if type_tags.len() != 1 || type_tags[0].get(1).map(String::as_str) != Some("work-report") { + return Err("work report requires t=work-report".to_string()); + } + let status_tags = values("status"); + if status_tags.len() != 1 || status_tags[0].get(1).map(String::as_str) != Some(status) { + return Err("work report status tag must match content".to_string()); + } + let priors = values("prior"); + if priors.len() > 1 + || priors + .first() + .is_some_and(|tag| tag.get(1).is_none_or(|id| !is_hex64(id))) + { + return Err("work report prior must be a single 64-char event id".to_string()); + } + Ok(()) +} + /// Validate the public envelope of a NIP-AE `kind:30174` event before it /// reaches NIP-33 parameterized replacement. /// @@ -2725,6 +2822,11 @@ async fn ingest_event_inner( validate_diff_event(&event).map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_WORK_REPORT { + validate_work_report_event(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_AGENT_ENGRAM { validate_engram_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3283,7 +3385,7 @@ mod tests { use buzz_core::kind::{ KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_DIFF, KIND_TEAM, KIND_USER_STATUS, + KIND_STREAM_MESSAGE_DIFF, KIND_TEAM, KIND_USER_STATUS, KIND_WORK_REPORT, }; use nostr::{EventBuilder, Kind}; @@ -4013,6 +4115,40 @@ mod tests { assert!(required_scope_for_kind(99999, &dummy).is_err()); } + fn work_report_event(status_tag: &str, content: &str) -> Event { + let root = "ab".repeat(32); + let channel_id = Uuid::new_v4().to_string(); + EventBuilder::new(Kind::Custom(KIND_WORK_REPORT as u16), content) + .tags([ + nostr::Tag::parse(["h", channel_id.as_str()]).unwrap(), + nostr::Tag::parse(["e", root.as_str(), "", "root"]).unwrap(), + nostr::Tag::parse(["t", "work-report"]).unwrap(), + nostr::Tag::parse(["status", status_tag]).unwrap(), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .unwrap() + } + + #[test] + fn work_report_is_channel_scoped_and_messages_write() { + let event = work_report_event("completed", r#"{"status":"completed","outcome":"Shipped"}"#); + assert!(requires_h_channel_scope(KIND_WORK_REPORT)); + assert_eq!( + required_scope_for_kind(KIND_WORK_REPORT, &event).unwrap(), + Scope::MessagesWrite + ); + assert!(validate_work_report_event(&event).is_ok()); + } + + #[test] + fn work_report_rejects_status_mismatch_and_empty_outcome() { + let mismatch = + work_report_event("completed", r#"{"status":"blocked","outcome":"Waiting"}"#); + assert!(validate_work_report_event(&mismatch).is_err()); + let empty = work_report_event("completed", r#"{"status":"completed","outcome":" "}"#); + assert!(validate_work_report_event(&empty).is_err()); + } + #[test] fn gift_wrap_is_in_scope_allowlist() { // KIND_GIFT_WRAP is still in the per-kind scope allowlist. diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73db..477b105fcb6 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -385,6 +385,61 @@ pub fn build_diff_message( Ok(EventBuilder::new(Kind::Custom(40008), content).tags(tags)) } +/// Build a structured work report rooted in a channel thread (kind 40009). +pub fn build_work_report( + channel_id: Uuid, + thread_root: nostr::EventId, + prior: Option, + report: &crate::WorkReport, +) -> Result { + let outcome = report.outcome.trim(); + if outcome.is_empty() { + return Err(SdkError::InvalidInput( + "work report outcome is required".into(), + )); + } + if outcome.len() > 1_024 { + return Err(SdkError::InvalidInput( + "work report outcome exceeds 1024 bytes".into(), + )); + } + for (field, values) in [ + ("deliverables", &report.deliverables), + ("decisions", &report.decisions), + ("verification", &report.verification), + ("risks", &report.risks), + ("next_actions", &report.next_actions), + ] { + if values.len() > 20 { + return Err(SdkError::InvalidInput(format!( + "work report {field} exceeds 20 entries" + ))); + } + if values + .iter() + .any(|value| value.trim().is_empty() || value.len() > 2_048) + { + return Err(SdkError::InvalidInput(format!( + "work report {field} entries must be non-empty and at most 2048 bytes" + ))); + } + } + + let content = serde_json::to_string(report) + .map_err(|error| SdkError::InvalidInput(format!("invalid work report: {error}")))?; + check_content(&content, 32 * 1024)?; + let mut tags = vec![ + tag(&["h", &channel_id.to_string()])?, + tag(&["e", &thread_root.to_hex(), "", "root"])?, + tag(&["t", "work-report"])?, + tag(&["status", report.status.as_str()])?, + ]; + if let Some(prior) = prior { + tags.push(tag(&["prior", &prior.to_hex()])?); + } + Ok(EventBuilder::new(Kind::Custom(40009), content).tags(tags)) +} + /// Build an edit event targeting an existing message (kind 40003). pub fn build_edit( channel_id: Uuid, @@ -2630,6 +2685,59 @@ mod tests { assert!(has_tag(&ev, "l", "rust")); } + fn completed_report() -> crate::WorkReport { + crate::WorkReport { + status: crate::WorkReportStatus::Completed, + outcome: "Shipped the result-first report contract".into(), + deliverables: vec!["https://example.com/pr/1".into()], + decisions: vec!["Keep raw conversation as evidence".into()], + verification: vec!["SDK and CLI tests passed".into()], + risks: vec![], + next_actions: vec!["Maintainer: review the PR".into()], + } + } + + #[test] + fn work_report_has_root_status_and_prior_contract() { + let channel_id = uuid(); + let root = event_id(); + let prior = event_id(); + let event = + sign(build_work_report(channel_id, root, Some(prior), &completed_report()).unwrap()); + assert_eq!(event.kind.as_u16(), 40009); + assert!(has_tag(&event, "h", &channel_id.to_string())); + assert!(has_tag(&event, "t", "work-report")); + assert!(has_tag(&event, "status", "completed")); + assert!(has_tag(&event, "prior", &prior.to_hex())); + let root_tag = event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("e")) + .expect("root tag"); + assert_eq!(root_tag.as_slice().get(1), Some(&root.to_hex())); + assert_eq!(root_tag.as_slice().get(3).map(String::as_str), Some("root")); + let body: crate::WorkReport = serde_json::from_str(&event.content).unwrap(); + assert_eq!(body, completed_report()); + } + + #[test] + fn work_report_rejects_empty_outcome_and_oversized_lists() { + let channel_id = uuid(); + let root = event_id(); + let mut report = completed_report(); + report.outcome = " ".into(); + assert!(matches!( + build_work_report(channel_id, root, None, &report), + Err(SdkError::InvalidInput(_)) + )); + report = completed_report(); + report.verification = (0..21).map(|index| format!("check {index}")).collect(); + assert!(matches!( + build_work_report(channel_id, root, None, &report), + Err(SdkError::InvalidInput(_)) + )); + } + #[test] fn diff_message_bad_repo_url() { let cid = uuid(); diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 845505c56d5..5bf8cc5ca84 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -57,6 +57,59 @@ pub struct DiffMeta { pub alt_text: Option, } +/// Machine-readable status of a work report (kind 40009). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkReportStatus { + /// Work and verification are complete. + Completed, + /// Work is ready for review but not yet shipped. + InReview, + /// A human decision is required before progress can continue. + NeedsDecision, + /// Work cannot continue until an external dependency changes. + Blocked, + /// Work ended unsuccessfully. + Failed, +} + +impl WorkReportStatus { + /// Stable wire value used in the event status tag and JSON body. + pub const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::InReview => "in_review", + Self::NeedsDecision => "needs_decision", + Self::Blocked => "blocked", + Self::Failed => "failed", + } + } +} + +/// Structured outcome body for a work report (kind 40009). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WorkReport { + /// Current outcome state. + pub status: WorkReportStatus, + /// One-sentence description of what changed or what is needed. + pub outcome: String, + /// Openable PR, file, document, or artifact references. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deliverables: Vec, + /// Material decisions and their rationale. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub decisions: Vec, + /// Tests, CI runs, runtime checks, or other evidence. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub verification: Vec, + /// Risks or limitations that affect use of the result. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub risks: Vec, + /// Action, owner, and optional timing for the next step. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub next_actions: Vec, +} + /// Vote direction for `build_vote`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoteDirection { diff --git a/docs/nips/NIP-WR.md b/docs/nips/NIP-WR.md new file mode 100644 index 00000000000..881f8ef6498 --- /dev/null +++ b/docs/nips/NIP-WR.md @@ -0,0 +1,66 @@ +# NIP-WR: Thread-Rooted Work Reports + +`kind:40009` is a signed, channel-scoped outcome report for one Buzz thread. +It keeps the full conversation as evidence while giving clients a compact, +machine-readable result to present first. + +## Event + +```json +{ + "kind": 40009, + "tags": [ + ["h", ""], + ["e", "", "", "root"], + ["t", "work-report"], + ["status", "completed"], + ["prior", ""] + ], + "content": "{...}" +} +``` + +The `prior` tag is omitted for the first report and required by the CLI when a +head already exists. Clients reduce a thread to the newest `(created_at, id)` +valid report and use `prior` to detect stale updates. The source events remain +immutable and available as the revision history. + +## Content + +Content is a JSON object: + +```json +{ + "status": "completed", + "outcome": "Shipped the result-first report contract.", + "deliverables": ["https://example.com/pr/1"], + "decisions": ["Keep raw conversation as evidence."], + "verification": ["CI passed at abc1234."], + "risks": [], + "next_actions": ["Maintainer: review the PR."] +} +``` + +`status` is one of `completed`, `in_review`, `needs_decision`, `blocked`, or +`failed`. `outcome` is required and limited to 1024 bytes. The remaining arrays +are optional; each accepts at most 20 non-empty strings of at most 2048 bytes. +The complete serialized content is limited to 32 KiB. + +The `status` tag must exactly match the JSON status. This permits clients to +filter attention states without parsing content while preventing two competing +representations of the result. + +## Authorization and privacy + +Work reports require the same `MessagesWrite` scope and channel membership as +other channel content. The relay requires a valid `h` tag; reports in private +channels are readable only through the existing channel boundary. Reports are +not an automatic LLM summary: the event signature identifies who asserted the +result, and the root reference preserves access to the source conversation. + +## Presentation + +Clients should present the latest valid work report before the transcript and +offer the underlying conversation and execution log through progressive +disclosure. They must not delete or rewrite source messages when a report is +published.