//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping. //! //! Each tool here is a thin, typed face over an **already-existing** //! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur //! MCP n'invente aucune sémantique »). The mapping builds an //! [`OrchestratorRequest`] from the tool arguments and lets //! [`OrchestratorRequest::validate`] be the **single source of truth** for //! validation — the very same path the filesystem watcher takes. No tool //! validates anything itself; no tool reaches a use case directly. //! //! ## Agent discovery (`idea_list_agents`) //! //! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is //! served through the **same** `OrchestratorService::dispatch` as every other tool //! (no use-case is reached directly). Its success carries the agent list inline as //! a JSON array in the outcome's reply — see [`tool_returns_reply`]. use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; use serde_json::{json, Value}; /// A tool the IdeA MCP server advertises, with the JSON Schema MCP clients read /// from `tools/list`. pub struct ToolDef { /// Tool name as seen by the agent (`idea_ask_agent`, …). pub name: &'static str, /// One-line human description shown in the client. pub description: &'static str, /// JSON Schema (object) for the tool's arguments. pub input_schema: Value, } /// Errors mapping a tool call into a validated [`OrchestratorCommand`]. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ToolMapError { /// The tool name is not one this server exposes. #[error("unknown tool: {0}")] UnknownTool(String), /// The `arguments` object was absent or not a JSON object. #[error("tool `{0}` arguments must be a JSON object")] BadArguments(String), /// The arguments did not satisfy the underlying command's invariants. #[error(transparent)] Invalid(#[from] OrchestratorError), } /// Whether a successful call of `tool` carries an inline reply payload back to the /// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent /// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to /// the awaiting requester, not echoed inline), so it is **not** in this set. #[must_use] pub fn tool_returns_reply(tool: &str) -> bool { matches!( tool, "idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read" | "idea_skill_read" | "idea_workstate_read" ) } /// The full catalogue advertised on `tools/list`. /// /// Exactly the tools whose mapping target already exists as an /// [`OrchestratorCommand`]. #[must_use] pub fn catalogue() -> Vec { vec![ ToolDef { name: "idea_list_agents", description: "List the IdeA agents declared in the project's manifest. Returns the \ agents inline as a JSON array (id, name, profile, origin, …).", input_schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }), }, ToolDef { name: "idea_ask_agent", description: "Ask another IdeA agent a task and wait for its reply (synchronous \ inter-agent rendezvous). Returns the target agent's answer inline.", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Target agent display name." }, "task": { "type": "string", "description": "The task/message to send." } }, "required": ["target", "task"], "additionalProperties": false }), }, ToolDef { name: "idea_reply", description: "Render the result of the task you are currently processing (the one IdeA \ delegated to you as `[IdeA · tâche … · ticket ]`). Call this — never \ answer in plain text — so IdeA can hand your answer back to the agent that \ asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \ reply exactly, even when you handle several requests.", input_schema: json!({ "type": "object", "properties": { "result": { "type": "string", "description": "The result/answer to deliver to the requester." }, "ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket ]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." } }, "required": ["result"], "additionalProperties": false }), }, ToolDef { name: "idea_launch_agent", description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ has started the agent, without waiting for any work.", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Target agent display name." }, "profile": { "type": "string", "description": "Optional profile reference for a not-yet-created agent." }, "task": { "type": "string", "description": "Optional initial task/context for the launched agent." }, "visibility": { "type": "string", "enum": ["background", "visible"], "description": "Where to place the session (default background)." }, "nodeId": { "type": "string", "description": "Target layout cell id, required when visibility is \"visible\"." } }, "required": ["target"], "additionalProperties": false }), }, ToolDef { name: "idea_stop_agent", description: "Stop a running IdeA agent by killing its terminal session.", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Target agent display name." } }, "required": ["target"], "additionalProperties": false }), }, ToolDef { name: "idea_update_context", description: "Overwrite an IdeA agent's `.md` context with a new Markdown body.", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Target agent display name." }, "context": { "type": "string", "description": "New Markdown body." } }, "required": ["target", "context"], "additionalProperties": false }), }, ToolDef { name: "idea_context_read", description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \ Omit `target` for the global project context, or pass an agent's display \ name for that agent's `.md`. Reading is shared (never blocks other readers).", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Agent display name. Omit for the global project context." } }, "additionalProperties": false }), }, ToolDef { name: "idea_context_propose", description: "Propose new Markdown content for an IdeA-owned context (under the file \ guard). For an agent's `.md` this writes directly; for the **global** \ project context it is only a *proposal* (the global context is \ single-writer — reserved to the orchestrator — so your change is recorded \ for validation, not applied).", input_schema: json!({ "type": "object", "properties": { "target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." }, "content": { "type": "string", "description": "The proposed Markdown body." } }, "required": ["content"], "additionalProperties": false }), }, ToolDef { name: "idea_memory_read", description: "Read project memory (under the file guard). Omit `slug` for the aggregated \ index, or pass a note slug for one note.", input_schema: json!({ "type": "object", "properties": { "slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." } }, "additionalProperties": false }), }, ToolDef { name: "idea_memory_write", description: "Write (create or replace) a project memory note under the file guard. \ Memory is shared across the project's agents.", input_schema: json!({ "type": "object", "properties": { "slug": { "type": "string", "description": "Memory note slug (kebab-case)." }, "content": { "type": "string", "description": "The Markdown body to store." } }, "required": ["slug", "content"], "additionalProperties": false }), }, ToolDef { name: "idea_skill_read", description: "Read the full body of an IdeA skill assigned to you, by name — call this \ to actually execute/follow a skill listed in your « Skills disponibles » \ section. Resolves project scope first, then global. Returns the skill's \ Markdown inline.", input_schema: json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Skill name (as listed in your context)." } }, "required": ["name"], "additionalProperties": false }), }, ToolDef { name: "idea_workstate_read", description: "Read what every IdeA agent is doing right now (the project live-state). \ Returns a JSON array, one row per agent: name, status \ (idle|working|blocked|waiting|done), short intent, and optional \ current/last-delegation ticket. Last-writer-wins, not real-time.", input_schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }), }, ToolDef { name: "idea_workstate_set", description: "Publish your own current status to the project live-state so other \ agents can coordinate. Writes only YOUR row (keyed on your identity). \ `status` is required; `intent`/`progress`/`ticket`/`lastDelegation` are \ optional. Acknowledged only (no reply).", input_schema: json!({ "type": "object", "properties": { "status": { "type": "string", "enum": ["idle", "working", "blocked", "waiting", "done"], "description": "Your coarse status right now." }, "intent": { "type": "string", "description": "Short description of what you are doing." }, "progress": { "type": "string", "description": "Optional finer-grained progress note." }, "ticket": { "type": "string", "description": "Optional id of the ticket you are currently handling." }, "lastDelegation": { "type": "string", "description": "Optional id of the most recent delegation you issued." } }, "required": ["status"], "additionalProperties": false }), }, ToolDef { name: "idea_create_skill", description: "Create a reusable IdeA skill (Markdown) in the project or global scope.", input_schema: json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Skill name (also the .md stem)." }, "context": { "type": "string", "description": "Markdown body of the skill." }, "scope": { "type": "string", "enum": ["project", "global"], "description": "Scope (default project)." } }, "required": ["name", "context"], "additionalProperties": false }), }, ] } /// Maps an MCP `tools/call` (`name` + `arguments`) to a validated /// [`OrchestratorCommand`], reusing [`OrchestratorRequest::validate`] as the one /// validation authority. /// /// `arguments` is the raw object an MCP client passes under `params.arguments`. /// `requester` is the **connected peer's agent id** (from the loopback handshake, /// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation /// uses the handshake identity, never a model-managed value. Every other tool /// ignores it. /// /// # Errors /// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`], /// - [`ToolMapError::BadArguments`] when `arguments` is not an object, /// - [`ToolMapError::Invalid`] when the underlying command's invariants fail. pub fn map_tool_call( name: &str, arguments: &Value, requester: &str, ) -> Result { let args = arguments .as_object() .ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?; // Small helpers to pull optional string fields out of the arguments object. let s = |key: &str| -> Option { args.get(key).and_then(Value::as_str).map(str::to_owned) }; // Translate the tool into the wire-level request shape the watcher already // uses (v2 `type`), then let `validate` enforce the invariants once. let request = match name { "idea_list_agents" => OrchestratorRequest { request_type: Some("agent.list".to_owned()), ..base() }, "idea_ask_agent" => OrchestratorRequest { request_type: Some("agent.message".to_owned()), // The **requester** is the connected peer's handshake identity (never a // tool argument), injected as `requestedBy` so `validate` can route the // ask on the A↔B conversation and feed the wait-for guard (cadrage C3). requested_by: Some(requester.to_owned()), target_agent: s("target"), task: s("task"), ..base() }, "idea_reply" => OrchestratorRequest { request_type: Some("agent.reply".to_owned()), // `from` is the handshake identity, not a tool argument: inject the peer's // requester id as `requestedBy` so `validate` builds `Reply { from, .. }`. requested_by: Some(requester.to_owned()), // The agent echoes the `ticket` it received in the `[IdeA · … · ticket // ]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3). ticket: s("ticket"), result: s("result"), ..base() }, "idea_launch_agent" => OrchestratorRequest { request_type: Some("agent.run".to_owned()), target_agent: s("target"), profile: s("profile"), task: s("task"), visibility: s("visibility"), node_id: parse_node_id(args.get("nodeId")), ..base() }, "idea_stop_agent" => OrchestratorRequest { request_type: Some("agent.stop".to_owned()), target_agent: s("target"), ..base() }, "idea_update_context" => OrchestratorRequest { request_type: Some("agent.update_context".to_owned()), target_agent: s("target"), context: s("context"), ..base() }, "idea_context_read" => OrchestratorRequest { request_type: Some("context.read".to_owned()), // The requester (handshake identity) drives the read lease's `who`. requested_by: Some(requester.to_owned()), target_agent: s("target"), ..base() }, "idea_context_propose" => OrchestratorRequest { request_type: Some("context.propose".to_owned()), requested_by: Some(requester.to_owned()), target_agent: s("target"), content: s("content"), ..base() }, "idea_memory_read" => OrchestratorRequest { request_type: Some("memory.read".to_owned()), requested_by: Some(requester.to_owned()), slug: s("slug"), ..base() }, "idea_memory_write" => OrchestratorRequest { request_type: Some("memory.write".to_owned()), requested_by: Some(requester.to_owned()), slug: s("slug"), content: s("content"), ..base() }, "idea_skill_read" => OrchestratorRequest { request_type: Some("skill.read".to_owned()), // The requester (handshake identity) is carried for symmetry with the // other read tools; `validate` derives the requester party from it. requested_by: Some(requester.to_owned()), name: s("name"), ..base() }, "idea_workstate_read" => OrchestratorRequest { request_type: Some("workstate.read".to_owned()), // The requester (handshake identity) is carried for symmetry with the // other read tools; the read is project-wide. requested_by: Some(requester.to_owned()), ..base() }, "idea_workstate_set" => OrchestratorRequest { request_type: Some("workstate.set".to_owned()), // The written key is the connected peer's handshake identity (never a tool // argument): injected as `requestedBy` so `validate` keys the row on it. requested_by: Some(requester.to_owned()), status: s("status"), intent: s("intent"), progress: s("progress"), ticket: s("ticket"), last_delegation: s("lastDelegation"), ..base() }, "idea_create_skill" => OrchestratorRequest { request_type: Some("skill.create".to_owned()), name: s("name"), context: s("context"), scope: s("scope"), ..base() }, other => return Err(ToolMapError::UnknownTool(other.to_owned())), }; let command = request.validate()?; // Diagnostics mapping beacon (best-effort) pour les deux tools du rendezvous : // `idea_ask_agent` et `idea_reply`. Longueurs et présence de ticket uniquement — // jamais le corps `task`/`result` ni de secret. Permet de corréler un `[mcp]` // begin avec la commande effectivement construite (kind/target/requester). match name { "idea_ask_agent" => application::diag!( "[mcp] mapped kind=ask_agent target={} task_len={} requester={}", s("target").as_deref().unwrap_or("-"), s("task").map_or(0, |t| t.len()), if requester.is_empty() { "-" } else { requester }, ), "idea_reply" => application::diag!( "[mcp] mapped kind=reply result_len={} ticket={} requester={}", s("result").map_or(0, |r| r.len()), if s("ticket").is_some() { "present" } else { "absent" }, if requester.is_empty() { "-" } else { requester }, ), _ => {} } Ok(command) } /// An all-`None` request to spread over; keeps each arm above to just its fields. fn base() -> OrchestratorRequest { OrchestratorRequest { action: None, request_type: None, requested_by: None, name: None, target_agent: None, profile: None, context: None, task: None, visibility: None, node_id: None, attach_to_cell: None, scope: None, result: None, ticket: None, content: None, slug: None, status: None, intent: None, progress: None, last_delegation: None, } } /// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently /// dropping a malformed/absent one (validation then rejects a `visible` launch /// missing its node, with a precise field error). fn parse_node_id(value: Option<&Value>) -> Option { let raw = value?.as_str()?; uuid::Uuid::parse_str(raw) .ok() .map(domain::NodeId::from_uuid) } #[cfg(test)] mod tests { use super::*; use domain::OrchestratorVisibility; /// A well-formed handshake requester id for the tests (parsed as an `AgentId`). const REQ: &str = "11111111-1111-1111-1111-111111111111"; /// Maps a tool call with an empty requester (the default for tools that ignore it). fn map(name: &str, args: &Value) -> Result { map_tool_call(name, args, "") } #[test] fn ask_agent_maps_to_ask_command() { let cmd = map( "idea_ask_agent", &json!({ "target": "Architect", "task": "Analyse §17" }), ) .unwrap(); assert_eq!( cmd, OrchestratorCommand::AskAgent { target: "Architect".to_owned(), task: "Analyse §17".to_owned(), // `map` passes an empty requester ⇒ no machine requester carried. requester: None, } ); } #[test] fn launch_agent_maps_to_spawn_background_by_default() { let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap(); assert_eq!( cmd, OrchestratorCommand::SpawnAgent { name: "Dev".to_owned(), profile: None, context: None, visibility: OrchestratorVisibility::Background, } ); } #[test] fn stop_update_and_skill_map_to_their_commands() { assert_eq!( map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(), OrchestratorCommand::StopAgent { name: "Dev".to_owned() } ); assert_eq!( map( "idea_update_context", &json!({ "target": "Dev", "context": "# body" }) ) .unwrap(), OrchestratorCommand::UpdateAgentContext { name: "Dev".to_owned(), context: "# body".to_owned(), } ); assert_eq!( map( "idea_create_skill", &json!({ "name": "deploy", "context": "# steps" }) ) .unwrap(), OrchestratorCommand::CreateSkill { name: "deploy".to_owned(), content: "# steps".to_owned(), scope: domain::SkillScope::Project, } ); } #[test] fn missing_required_field_surfaces_validation_error() { let err = map("idea_ask_agent", &json!({ "target": "Architect" })); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); } #[test] fn list_agents_maps_to_list_command() { let cmd = map("idea_list_agents", &json!({})).unwrap(); assert_eq!(cmd, OrchestratorCommand::ListAgents); } #[test] fn unknown_tool_is_rejected() { let err = map("idea_made_up_tool", &json!({})); assert_eq!( err, Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned())) ); } #[test] fn non_object_arguments_are_rejected() { let err = map("idea_ask_agent", &json!("nope")); assert_eq!( err, Err(ToolMapError::BadArguments("idea_ask_agent".to_owned())) ); } #[test] fn reply_maps_with_handshake_requester_as_from() { // `from` comes from the handshake requester, NOT a tool argument. let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap(); assert_eq!( cmd, OrchestratorCommand::Reply { from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), ticket: None, result: "the answer".to_owned(), } ); } #[test] fn reply_echoes_ticket_when_provided() { let tkt = "22222222-2222-2222-2222-222222222222"; let cmd = map_tool_call( "idea_reply", &json!({ "result": "done", "ticket": tkt }), REQ, ) .unwrap(); assert_eq!( cmd, OrchestratorCommand::Reply { from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), ticket: Some(domain::mailbox::TicketId::from_uuid( uuid::Uuid::parse_str(tkt).unwrap() )), result: "done".to_owned(), } ); } #[test] fn ask_agent_injects_handshake_requester() { let cmd = map_tool_call( "idea_ask_agent", &json!({ "target": "B", "task": "go" }), REQ, ) .unwrap(); assert_eq!( cmd, OrchestratorCommand::AskAgent { target: "B".to_owned(), task: "go".to_owned(), requester: Some(domain::AgentId::from_uuid( uuid::Uuid::parse_str(REQ).unwrap() )), } ); } #[test] fn reply_without_result_is_a_validation_error() { let err = map_tool_call("idea_reply", &json!({}), REQ); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); } #[test] fn reply_with_blank_or_missing_requester_is_a_validation_error() { // No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic). let err = map_tool_call("idea_reply", &json!({ "result": "x" }), ""); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); // A non-uuid requester is rejected too. let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid"); assert!(matches!(err2, Err(ToolMapError::Invalid(_)))); } #[test] fn context_read_maps_with_requester_party() { // Global (no target) with a uuid requester ⇒ Agent party. let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap(); assert_eq!( cmd, OrchestratorCommand::ReadContext { target: None, requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( uuid::Uuid::parse_str(REQ).unwrap() )), } ); } #[test] fn context_propose_requires_content() { let ok = map_tool_call( "idea_context_propose", &json!({ "target": "Dev", "content": "# body" }), REQ, ) .unwrap(); assert_eq!( ok, OrchestratorCommand::ProposeContext { target: Some("Dev".to_owned()), content: "# body".to_owned(), requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( uuid::Uuid::parse_str(REQ).unwrap() )), } ); let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); } #[test] fn memory_read_and_write_map_to_their_commands() { assert_eq!( map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(), OrchestratorCommand::ReadMemory { slug: Some("note-a".to_owned()), requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( uuid::Uuid::parse_str(REQ).unwrap() )), } ); assert_eq!( map_tool_call( "idea_memory_write", &json!({ "slug": "note-a", "content": "hi" }), REQ ) .unwrap(), OrchestratorCommand::WriteMemory { slug: "note-a".to_owned(), content: "hi".to_owned(), requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( uuid::Uuid::parse_str(REQ).unwrap() )), } ); // memory.write without content ⇒ validation error. let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); } #[test] fn reply_is_not_an_inline_reply_tool() { // ACK only: the result is routed to the awaiting requester, not echoed. assert!(!tool_returns_reply("idea_reply")); } #[test] fn workstate_read_maps_with_requester_party() { let cmd = map_tool_call("idea_workstate_read", &json!({}), REQ).unwrap(); assert_eq!( cmd, OrchestratorCommand::ReadWorkState { requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( uuid::Uuid::parse_str(REQ).unwrap() )), } ); // Read carries an inline reply (the live-state JSON array); set does not. assert!(tool_returns_reply("idea_workstate_read")); assert!(!tool_returns_reply("idea_workstate_set")); } #[test] fn workstate_set_keys_on_handshake_requester() { let tkt = "22222222-2222-2222-2222-222222222222"; let cmd = map_tool_call( "idea_workstate_set", &json!({ "status": "working", "intent": "ship", "ticket": tkt }), REQ, ) .unwrap(); assert_eq!( cmd, OrchestratorCommand::SetWorkState { agent: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), status: domain::live_state::WorkStatus::Working, intent: Some("ship".to_owned()), progress: None, ticket: Some(domain::mailbox::TicketId::from_uuid( uuid::Uuid::parse_str(tkt).unwrap() )), last_delegation: None, } ); // Missing status ⇒ validation error. let err = map_tool_call("idea_workstate_set", &json!({ "intent": "x" }), REQ); assert!(matches!(err, Err(ToolMapError::Invalid(_)))); // Status outside the enum ⇒ validation error too (UnknownWorkStatus surfaces // as the generic Invalid map error). let bad = map_tool_call("idea_workstate_set", &json!({ "status": "busy" }), REQ); assert!( matches!(bad, Err(ToolMapError::Invalid(_))), "an out-of-enum status must be rejected, got {bad:?}" ); } /// The catalogue advertises the two live-state tools (lot LS4) with schemas /// matching the frozen contract: `set` requires `status` constrained to the exact /// five-label enum; `read` takes no parameter. #[test] fn catalogue_advertises_workstate_tools_with_conformant_schemas() { let cat = catalogue(); let by_name = |name: &str| { cat.iter() .find(|t| t.name == name) .unwrap_or_else(|| panic!("catalogue must contain {name}")) }; // read — object schema, no declared properties, no required fields. let read = by_name("idea_workstate_read"); assert_eq!(read.input_schema["type"], json!("object")); assert_eq!( read.input_schema["properties"], json!({}), "read takes no parameter" ); assert!( read.input_schema.get("required").is_none(), "read requires nothing" ); // set — `status` required, enum exactly the five canonical labels. let set = by_name("idea_workstate_set"); assert_eq!(set.input_schema["type"], json!("object")); assert_eq!(set.input_schema["required"], json!(["status"])); assert_eq!( set.input_schema["properties"]["status"]["enum"], json!(["idle", "working", "blocked", "waiting", "done"]), "status enum must match WorkStatus labels exactly" ); } }