1use crate::config::{
8 AppConfig, Config, ConfigLoader, PhasesConfig, Prompts, ServerPaths, StatesConfig,
9 workflows::WorkflowsConfig,
10};
11use crate::db::Database;
12use crate::format::{OutputFormat, ToolResult};
13use crate::prompts as prompt_system;
14use crate::tools::{ToolHandler, advisories, agents, attachments, claiming, tasks, tracking};
15use anyhow::Result;
16use clap::{Args, Subcommand, ValueEnum};
17use serde_json::{Value, json};
18use std::path::PathBuf;
19use std::process::ExitCode;
20use std::sync::Arc;
21
22pub mod exit_codes {
24 pub const SUCCESS: u8 = 0;
25 pub const GENERAL_ERROR: u8 = 1;
26 pub const INVALID_ARGUMENTS: u8 = 2;
27 pub const TASK_NOT_FOUND: u8 = 3;
28 pub const WORKER_NOT_FOUND: u8 = 4;
29 pub const CLAIM_FAILED: u8 = 5;
30 pub const PERMISSION_DENIED: u8 = 6;
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
35pub enum CliOutputFormat {
36 #[default]
38 Markdown,
39 Json,
41}
42
43impl From<CliOutputFormat> for OutputFormat {
44 fn from(f: CliOutputFormat) -> Self {
45 match f {
46 CliOutputFormat::Markdown => OutputFormat::Markdown,
47 CliOutputFormat::Json => OutputFormat::Json,
48 }
49 }
50}
51
52#[derive(Args, Debug)]
54pub struct AgentArgs {
55 #[arg(long, global = true)]
57 pub worker_id: Option<String>,
58
59 #[arg(long, global = true, value_enum, default_value = "markdown")]
61 pub format: CliOutputFormat,
62
63 #[command(subcommand)]
64 pub command: AgentCommand,
65}
66
67#[derive(Subcommand, Debug)]
69pub enum AgentCommand {
70 Connect(ConnectArgs),
72
73 Disconnect(DisconnectArgs),
75
76 #[command(alias = "ls")]
78 ListTasks(ListTasksArgs),
79
80 Get(GetArgs),
82
83 Claim(ClaimArgs),
85
86 Update(UpdateArgs),
88
89 Thinking(ThinkingArgs),
91
92 Attach(AttachArgs),
94
95 ListAgents(ListAgentsArgs),
97
98 Prompts(PromptsArgs),
100
101 #[command(alias = "repl")]
103 Interactive(InteractiveArgs),
104
105 Batch(BatchArgs),
107}
108
109#[derive(Args, Debug)]
111pub struct InteractiveArgs {
112 #[arg(long)]
114 pub stdin: bool,
115}
116
117#[derive(Args, Debug)]
119pub struct BatchArgs {
120 pub file: PathBuf,
122
123 #[arg(long, short = 'k')]
125 pub keep_going: bool,
126}
127
128#[derive(Args, Debug)]
130pub struct ConnectArgs {
131 pub worker_id: Option<String>,
133
134 #[arg(long, value_delimiter = ',')]
136 pub tags: Vec<String>,
137
138 #[arg(long)]
140 pub workflow: Option<String>,
141
142 #[arg(long, value_delimiter = ',')]
144 pub overlays: Vec<String>,
145
146 #[arg(long)]
148 pub force: bool,
149}
150
151#[derive(Args, Debug)]
153pub struct DisconnectArgs {
154 pub worker_id: String,
156
157 #[arg(long)]
159 pub final_status: Option<String>,
160}
161
162#[derive(Args, Debug)]
164pub struct ListTasksArgs {
165 #[arg(long)]
167 pub ready: bool,
168
169 #[arg(long)]
171 pub blocked: bool,
172
173 #[arg(long, value_delimiter = ',')]
175 pub status: Vec<String>,
176
177 #[arg(long)]
179 pub parent: Option<String>,
180
181 #[arg(long)]
183 pub limit: Option<i32>,
184
185 #[arg(long)]
187 pub offset: Option<i32>,
188}
189
190#[derive(Args, Debug)]
192pub struct GetArgs {
193 pub task_id: String,
195}
196
197#[derive(Args, Debug)]
199pub struct ClaimArgs {
200 pub worker_id: String,
202
203 pub task_id: String,
205
206 #[arg(long)]
208 pub force: bool,
209}
210
211#[derive(Args, Debug)]
213pub struct UpdateArgs {
214 pub worker_id: String,
216
217 pub task_id: String,
219
220 #[arg(long)]
222 pub status: Option<String>,
223
224 #[arg(long)]
226 pub title: Option<String>,
227
228 #[arg(long)]
230 pub description: Option<String>,
231
232 #[arg(long)]
234 pub reason: Option<String>,
235
236 #[arg(long)]
238 pub force: bool,
239}
240
241#[derive(Args, Debug)]
243pub struct ThinkingArgs {
244 pub worker_id: String,
246
247 pub message: String,
249
250 #[arg(long, value_delimiter = ',')]
252 pub tasks: Vec<String>,
253}
254
255#[derive(Args, Debug)]
257pub struct AttachArgs {
258 pub worker_id: String,
260
261 pub task_id: String,
263
264 #[arg(long, short = 't')]
266 pub r#type: String,
267
268 #[arg(long, short = 'c', conflicts_with = "file")]
270 pub content: Option<String>,
271
272 #[arg(long, short = 'f', conflicts_with = "content")]
274 pub file: Option<PathBuf>,
275
276 #[arg(long)]
278 pub name: Option<String>,
279}
280
281#[derive(Args, Debug)]
283pub struct ListAgentsArgs {
284 #[arg(long, value_delimiter = ',')]
286 pub tags: Vec<String>,
287
288 #[arg(long)]
290 pub file: Option<String>,
291
292 #[arg(long)]
294 pub task: Option<String>,
295}
296
297#[derive(Args, Debug)]
299pub struct PromptsArgs {
300 #[arg(long)]
302 pub status: Option<String>,
303
304 #[arg(long)]
306 pub phase: Option<String>,
307
308 #[arg(long, num_args = 0..=1, default_missing_value = "")]
310 pub advisory: Option<String>,
311
312 #[arg(long)]
314 pub task: Option<String>,
315}
316
317fn build_tool_handler(
319 db: Arc<Database>,
320 config: &Config,
321 prompts: Arc<Prompts>,
322 workflows: Arc<WorkflowsConfig>,
323 server_paths: Arc<ServerPaths>,
324) -> ToolHandler {
325 let states_config: StatesConfig = workflows.as_ref().into();
327 let phases_config: PhasesConfig = workflows.as_ref().into();
328
329 let states_config = Arc::new(states_config);
331 let phases_config = Arc::new(phases_config);
332 let deps_config = Arc::new(config.dependencies.clone());
333 let auto_advance = Arc::new(config.auto_advance.clone());
334 let attachments_config = Arc::new(config.attachments.clone());
335 let mut tags_config = config.tags.clone();
336 tags_config.register_workflow_tags(&workflows.all_role_tags());
337 let tags_config = Arc::new(tags_config);
338 let ids_config = Arc::new(config.ids.clone());
339 let feedback_config = Arc::new(config.feedback.clone());
340
341 let app_config = AppConfig::new(
342 states_config,
343 phases_config,
344 deps_config,
345 auto_advance,
346 attachments_config,
347 tags_config,
348 ids_config,
349 workflows,
350 feedback_config,
351 );
352
353 let path_mapper = Arc::new(
355 crate::paths::PathMapper::from_config(&config.paths, Some(config))
356 .unwrap_or_else(|_| crate::paths::PathMapper::default()),
357 );
358
359 ToolHandler::new(
360 db,
361 config.server.media_dir.clone(),
362 config.server.skills_dir.clone(),
363 server_paths,
364 prompts,
365 app_config,
366 config.server.default_format,
367 config.server.default_page_size,
368 path_mapper,
369 )
370}
371
372fn format_output(result: ToolResult, format: CliOutputFormat) -> String {
374 match result {
375 ToolResult::Json(v) => {
376 if format == CliOutputFormat::Json {
377 serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
378 } else {
379 serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
381 }
382 }
383 ToolResult::Raw(s) => {
384 if format == CliOutputFormat::Json {
385 json!({ "output": s }).to_string()
387 } else {
388 s
389 }
390 }
391 }
392}
393
394fn format_json_output(value: Value, format: CliOutputFormat) -> String {
396 if format == CliOutputFormat::Json {
397 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
398 } else {
399 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
400 }
401}
402
403fn format_connect_output(value: Value, format: CliOutputFormat) -> String {
405 if format == CliOutputFormat::Json {
406 return serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
407 }
408
409 let mut out = String::new();
410
411 if let Some(wid) = value.get("worker_id").and_then(|v| v.as_str()) {
413 out.push_str(&format!("**Worker ID:** `{}`\n", wid));
414 }
415 if let Some(tags) = value.get("tags").and_then(|v| v.as_array()) {
416 let tag_list: Vec<&str> = tags.iter().filter_map(|t| t.as_str()).collect();
417 if !tag_list.is_empty() {
418 out.push_str(&format!("**Tags:** {}\n", tag_list.join(", ")));
419 }
420 }
421 if let Some(workflow) = value.get("workflow").and_then(|v| v.as_str()) {
422 out.push_str(&format!("**Workflow:** {}\n", workflow));
423 }
424 if let Some(overlays) = value.get("overlays").and_then(|v| v.as_array()) {
425 let names: Vec<&str> = overlays.iter().filter_map(|o| o.as_str()).collect();
426 if !names.is_empty() {
427 out.push_str(&format!("**Overlays:** {}\n", names.join(", ")));
428 }
429 }
430
431 if let Some(role) = value.get("role") {
433 out.push('\n');
434 if let Some(name) = role.get("role").and_then(|v| v.as_str()) {
435 out.push_str(&format!("**Role:** `{}`", name));
436 }
437 if let Some(desc) = role.get("description").and_then(|v| v.as_str()) {
438 out.push_str(&format!(" - {}", desc));
439 }
440 out.push('\n');
441 }
442
443 if let Some(config) = value.get("config") {
445 out.push_str("\n### State Machine\n");
446 if let Some(initial) = config.get("initial_state").and_then(|v| v.as_str()) {
447 out.push_str(&format!("- **Initial:** `{}`\n", initial));
448 }
449 if let Some(states) = config.get("states").and_then(|v| v.as_array()) {
450 let names: Vec<&str> = states.iter().filter_map(|s| s.as_str()).collect();
451 out.push_str(&format!("- **States:** {}\n", names.join(", ")));
452 }
453 if let Some(timed) = config.get("timed_states").and_then(|v| v.as_array()) {
454 let names: Vec<&str> = timed.iter().filter_map(|s| s.as_str()).collect();
455 if !names.is_empty() {
456 out.push_str(&format!("- **Timed:** {}\n", names.join(", ")));
457 }
458 }
459 if let Some(terminal) = config.get("terminal_states").and_then(|v| v.as_array()) {
460 let names: Vec<&str> = terminal.iter().filter_map(|s| s.as_str()).collect();
461 if !names.is_empty() {
462 out.push_str(&format!("- **Terminal:** {}\n", names.join(", ")));
463 }
464 }
465 if let Some(phases) = config.get("phases").and_then(|v| v.as_array()) {
466 let names: Vec<&str> = phases.iter().filter_map(|s| s.as_str()).collect();
467 if !names.is_empty() {
468 out.push_str(&format!("- **Phases:** {}\n", names.join(", ")));
469 }
470 }
471 }
472
473 if let Some(prompts) = value.get("role_prompts").and_then(|v| v.as_array()) {
475 out.push_str("\n### Role Prompts\n");
476 for prompt in prompts {
477 if let Some(text) = prompt.as_str() {
478 for line in text.lines() {
480 out.push_str(&format!("> {}\n", line));
481 }
482 out.push_str("---\n");
483 }
484 }
485 }
486
487 if let Some(desc) = value.get("workflow_description").and_then(|v| v.as_str()) {
489 out.push_str("\n### Workflow\n");
490 out.push_str(desc);
491 out.push('\n');
492 }
493
494 if let Some(paths) = value.get("paths") {
496 out.push_str("\n### Paths\n");
497 if let Some(db) = paths.get("db_path").and_then(|v| v.as_str()) {
498 out.push_str(&format!("- db: `{}`\n", db));
499 }
500 if let Some(media) = paths.get("media_dir").and_then(|v| v.as_str()) {
501 out.push_str(&format!("- media: `{}`\n", media));
502 }
503 if let Some(log) = paths.get("log_dir").and_then(|v| v.as_str()) {
504 out.push_str(&format!("- log: `{}`\n", log));
505 }
506 }
507
508 if let Some(warnings) = value.get("path_warnings").and_then(|v| v.as_array()) {
510 out.push_str("\n### Warnings\n");
511 for w in warnings {
512 if let Some(text) = w.as_str() {
513 out.push_str(&format!("- {}\n", text));
514 }
515 }
516 }
517 if let Some(warnings) = value.get("tag_warnings").and_then(|v| v.as_array()) {
518 for w in warnings {
519 if let Some(text) = w.as_str() {
520 out.push_str(&format!("- {}\n", text));
521 }
522 }
523 }
524
525 out
526}
527
528fn format_update_output(value: Value, format: CliOutputFormat) -> String {
530 if format == CliOutputFormat::Json {
531 return serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
532 }
533
534 let mut out = String::new();
535
536 if let Some(task_id) = value.get("task").and_then(|v| v.as_str()) {
538 out.push_str(&format!("**Task:** `{}`", task_id));
539 }
540 if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
541 out.push_str(&format!(" - {}", title));
542 }
543 out.push('\n');
544 if let Some(status) = value.get("status").and_then(|v| v.as_str()) {
545 out.push_str(&format!("**Status:** `{}`\n", status));
546 }
547 if let Some(phase) = value.get("phase").and_then(|v| v.as_str()) {
548 out.push_str(&format!("**Phase:** `{}`\n", phase));
549 }
550
551 format_prompts_section(&value, &mut out);
553
554 if let Some(hints) = value.get("advisory_hints").and_then(|v| v.as_array())
556 && !hints.is_empty()
557 {
558 let names: Vec<&str> = hints.iter().filter_map(|h| h.as_str()).collect();
559 out.push_str(&format!(
560 "\n**Advisories:** `get_advisory` topics: {}\n",
561 names.join(", ")
562 ));
563 }
564
565 if let Some(warnings) = value.get("warnings").and_then(|v| v.as_array()) {
567 for w in warnings {
568 if let Some(text) = w.as_str() {
569 out.push_str(&format!("- \u{26a0} {}\n", text));
570 }
571 }
572 }
573
574 out
575}
576
577fn format_claim_output(value: Value, format: CliOutputFormat) -> String {
579 if format == CliOutputFormat::Json {
580 return serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
581 }
582
583 let mut out = String::new();
584
585 if let Some(task_id) = value.get("task").and_then(|v| v.as_str()) {
587 out.push_str(&format!("**Task:** `{}`", task_id));
588 }
589 if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
590 out.push_str(&format!(" - {}", title));
591 }
592 out.push('\n');
593 if let Some(status) = value.get("status").and_then(|v| v.as_str()) {
594 out.push_str(&format!("**Status:** `{}`\n", status));
595 }
596 if let Some(owner) = value.get("owner").and_then(|v| v.as_str()) {
597 out.push_str(&format!("**Owner:** `{}`\n", owner));
598 }
599
600 format_prompts_section(&value, &mut out);
602
603 if let Some(hints) = value.get("advisory_hints").and_then(|v| v.as_array())
605 && !hints.is_empty()
606 {
607 let names: Vec<&str> = hints.iter().filter_map(|h| h.as_str()).collect();
608 out.push_str(&format!(
609 "\n**Advisories:** `get_advisory` topics: {}\n",
610 names.join(", ")
611 ));
612 }
613
614 out
615}
616
617fn format_prompts_section(value: &Value, out: &mut String) {
619 if let Some(prompts) = value.get("prompts").and_then(|v| v.as_array())
620 && !prompts.is_empty()
621 {
622 out.push_str("\n### Guidance\n");
623 for (i, prompt) in prompts.iter().enumerate() {
624 let text = prompt
626 .get("text")
627 .and_then(|v| v.as_str())
628 .or_else(|| prompt.as_str());
629 if let Some(text) = text {
630 if let Some(source) = prompt.get("source").and_then(|v| v.as_str()) {
632 out.push_str(&format!("*[{}]*\n", source));
633 }
634 for line in text.lines() {
635 out.push_str(&format!("> {}\n", line));
636 }
637 if i + 1 < prompts.len() {
638 out.push_str("\n---\n\n");
639 }
640 }
641 }
642 }
643}
644
645fn error_to_exit_code(err: &anyhow::Error) -> u8 {
647 if let Some(tool_err) = err.downcast_ref::<crate::error::ToolError>() {
649 use crate::error::ErrorCode;
650 return match tool_err.code {
651 ErrorCode::TaskNotFound | ErrorCode::FileNotFound | ErrorCode::AttachmentNotFound => {
652 exit_codes::TASK_NOT_FOUND
653 }
654 ErrorCode::AgentNotFound => exit_codes::WORKER_NOT_FOUND,
655 ErrorCode::AlreadyClaimed
656 | ErrorCode::LockConflict
657 | ErrorCode::DependencyCycle
658 | ErrorCode::DependencyNotSatisfied
659 | ErrorCode::GatesNotSatisfied
660 | ErrorCode::TagMismatch => exit_codes::CLAIM_FAILED,
661 ErrorCode::NotOwner => exit_codes::PERMISSION_DENIED,
662 ErrorCode::MissingRequiredField
663 | ErrorCode::InvalidFieldValue
664 | ErrorCode::InvalidState
665 | ErrorCode::InvalidPath
666 | ErrorCode::InvalidPrefix => exit_codes::INVALID_ARGUMENTS,
667 ErrorCode::AlreadyExists => exit_codes::CLAIM_FAILED,
668 ErrorCode::DatabaseError | ErrorCode::InternalError | ErrorCode::UnknownTool => {
669 exit_codes::GENERAL_ERROR
670 }
671 };
672 }
673
674 let msg = err.to_string().to_lowercase();
676 if msg.contains("not found") {
677 if msg.contains("task") {
678 exit_codes::TASK_NOT_FOUND
679 } else if msg.contains("worker") || msg.contains("agent") {
680 exit_codes::WORKER_NOT_FOUND
681 } else {
682 exit_codes::GENERAL_ERROR
683 }
684 } else if msg.contains("already claimed")
685 || msg.contains("dependency")
686 || msg.contains("blocked")
687 {
688 exit_codes::CLAIM_FAILED
689 } else if msg.contains("not own") || msg.contains("permission") {
690 exit_codes::PERMISSION_DENIED
691 } else if msg.contains("required") || msg.contains("invalid") {
692 exit_codes::INVALID_ARGUMENTS
693 } else {
694 exit_codes::GENERAL_ERROR
695 }
696}
697
698pub fn run_agent_command(args: AgentArgs) -> ExitCode {
700 let loader = match ConfigLoader::load() {
702 Ok(l) => l,
703 Err(e) => {
704 eprintln!("Error loading config: {}", e);
705 return ExitCode::from(exit_codes::GENERAL_ERROR);
706 }
707 };
708
709 let config = loader.config();
711
712 let prompts = Arc::new(loader.load_prompts());
714 let workflows = Arc::new(load_workflows(&loader, config));
715
716 let db = match Database::open(&config.server.db_path) {
718 Ok(db) => Arc::new(db),
719 Err(e) => {
720 eprintln!("Error opening database: {}", e);
721 return ExitCode::from(exit_codes::GENERAL_ERROR);
722 }
723 };
724
725 let server_paths = Arc::new(ServerPaths {
727 db_path: config.server.db_path.clone(),
728 media_dir: config.server.media_dir.clone(),
729 log_dir: config.server.log_dir.clone(),
730 config_path: loader.config_path().map(PathBuf::from),
731 });
732
733 let handler = build_tool_handler(
735 Arc::clone(&db),
736 config,
737 Arc::clone(&prompts),
738 Arc::clone(&workflows),
739 Arc::clone(&server_paths),
740 );
741
742 match &args.command {
744 AgentCommand::Interactive(cmd_args) => {
745 return run_interactive(&handler, &args, cmd_args);
746 }
747 AgentCommand::Batch(cmd_args) => {
748 return run_batch(&handler, &args, cmd_args);
749 }
750 _ => {}
751 }
752
753 let result = match &args.command {
754 AgentCommand::Connect(cmd_args) => run_connect(&handler, &args, cmd_args),
755 AgentCommand::Disconnect(cmd_args) => run_disconnect(&handler, &args, cmd_args),
756 AgentCommand::ListTasks(cmd_args) => run_list_tasks(&handler, &args, cmd_args),
757 AgentCommand::Get(cmd_args) => run_get(&handler, &args, cmd_args),
758 AgentCommand::Claim(cmd_args) => run_claim(&handler, &args, cmd_args),
759 AgentCommand::Update(cmd_args) => run_update(&handler, &args, cmd_args),
760 AgentCommand::Thinking(cmd_args) => run_thinking(&handler, &args, cmd_args),
761 AgentCommand::Attach(cmd_args) => run_attach(&handler, &args, cmd_args),
762 AgentCommand::ListAgents(cmd_args) => run_list_agents(&handler, &args, cmd_args),
763 AgentCommand::Prompts(cmd_args) => run_prompts(&handler, &args, cmd_args),
764 AgentCommand::Interactive(_) | AgentCommand::Batch(_) => unreachable!(),
766 };
767
768 match result {
769 Ok(output) => {
770 println!("{}", output);
771 ExitCode::from(exit_codes::SUCCESS)
772 }
773 Err(e) => {
774 eprintln!("Error: {}", e);
775 ExitCode::from(error_to_exit_code(&e))
776 }
777 }
778}
779
780pub fn run_agent_command_and_exit(args: AgentArgs) -> ! {
784 let exit_code = run_agent_command(args);
785 let code = match exit_code {
787 code if code == ExitCode::from(exit_codes::SUCCESS) => exit_codes::SUCCESS,
788 code if code == ExitCode::from(exit_codes::GENERAL_ERROR) => exit_codes::GENERAL_ERROR,
789 code if code == ExitCode::from(exit_codes::INVALID_ARGUMENTS) => {
790 exit_codes::INVALID_ARGUMENTS
791 }
792 code if code == ExitCode::from(exit_codes::TASK_NOT_FOUND) => exit_codes::TASK_NOT_FOUND,
793 code if code == ExitCode::from(exit_codes::WORKER_NOT_FOUND) => {
794 exit_codes::WORKER_NOT_FOUND
795 }
796 code if code == ExitCode::from(exit_codes::CLAIM_FAILED) => exit_codes::CLAIM_FAILED,
797 code if code == ExitCode::from(exit_codes::PERMISSION_DENIED) => {
798 exit_codes::PERMISSION_DENIED
799 }
800 _ => exit_codes::GENERAL_ERROR,
801 };
802 std::process::exit(code as i32);
803}
804
805fn load_workflows(loader: &ConfigLoader, config: &Config) -> WorkflowsConfig {
807 let default_workflow_name = config.server.default_workflow.clone();
808
809 let mut workflows = if let Some(ref name) = default_workflow_name {
811 match loader.load_workflow_by_name(name) {
812 Ok(workflow_config) => workflow_config,
813 Err(_) => loader.load_workflows(),
814 }
815 } else {
816 loader.load_workflows()
817 };
818
819 for name in loader.list_workflows() {
821 if let Ok(workflow_config) = loader.load_workflow_by_name(&name) {
822 workflows
823 .named_workflows
824 .insert(name, Arc::new(workflow_config));
825 }
826 }
827
828 for name in loader.list_overlays() {
830 if let Ok(overlay_config) = loader.load_overlay_by_name(&name) {
831 workflows
832 .named_overlays
833 .insert(name, Arc::new(overlay_config));
834 }
835 }
836
837 workflows
838}
839
840fn run_connect(handler: &ToolHandler, args: &AgentArgs, cmd_args: &ConnectArgs) -> Result<String> {
843 let worker_id = cmd_args
846 .worker_id
847 .clone()
848 .or_else(|| args.worker_id.clone());
849
850 let base_workflow = cmd_args
852 .workflow
853 .as_ref()
854 .and_then(|name| handler.config.workflows.get_named_workflow(name))
855 .map(Arc::clone)
856 .or_else(|| {
857 handler
858 .config
859 .workflows
860 .get_default_workflow()
861 .map(Arc::clone)
862 })
863 .unwrap_or_else(|| Arc::clone(&handler.config.workflows));
864
865 let workflow = if cmd_args.overlays.is_empty() {
867 base_workflow
868 } else {
869 let mut merged = (*base_workflow).clone();
870 for name in &cmd_args.overlays {
871 if let Some(overlay) = handler.config.workflows.named_overlays.get(name) {
872 merged.apply_overlay(overlay);
873 }
874 }
875 merged.active_overlays = cmd_args.overlays.clone();
876 Arc::new(merged)
877 };
878
879 let tool_args = json!({
880 "worker_id": worker_id,
881 "tags": cmd_args.tags,
882 "force": cmd_args.force,
883 "workflow": cmd_args.workflow,
884 "overlays": cmd_args.overlays
885 });
886
887 let result = agents::connect(
888 agents::ConnectOptions {
889 db: &handler.db,
890 server_paths: &handler.server_paths,
891 config: &handler.config,
892 workflows: &workflow,
893 },
894 tool_args,
895 )?;
896
897 Ok(format_connect_output(result, args.format))
898}
899
900fn run_disconnect(
901 handler: &ToolHandler,
902 args: &AgentArgs,
903 cmd_args: &DisconnectArgs,
904) -> Result<String> {
905 let states_config: StatesConfig = handler.config.workflows.as_ref().into();
907
908 let tool_args = json!({
909 "worker_id": &cmd_args.worker_id,
910 "final_status": cmd_args.final_status
911 });
912
913 let result = agents::disconnect(&handler.db, &states_config, tool_args)?;
914 Ok(format_json_output(result, args.format))
915}
916
917fn run_list_tasks(
918 handler: &ToolHandler,
919 args: &AgentArgs,
920 cmd_args: &ListTasksArgs,
921) -> Result<String> {
922 let states_config: StatesConfig = handler.config.workflows.as_ref().into();
923
924 let mut tool_args = json!({
925 "ready": cmd_args.ready,
926 "blocked": cmd_args.blocked,
927 "format": if args.format == CliOutputFormat::Json { "json" } else { "markdown" }
928 });
929
930 if !cmd_args.status.is_empty() {
931 tool_args["status"] = json!(cmd_args.status);
932 }
933 if let Some(ref parent) = cmd_args.parent {
934 tool_args["parent"] = json!(parent);
935 }
936 if let Some(limit) = cmd_args.limit {
937 tool_args["limit"] = json!(limit);
938 }
939 if let Some(offset) = cmd_args.offset {
940 tool_args["offset"] = json!(offset);
941 }
942
943 let result = tasks::list_tasks(
944 &handler.db,
945 &states_config,
946 &handler.config.deps,
947 args.format.into(),
948 tool_args,
949 )?;
950
951 Ok(format_output(result, args.format))
952}
953
954fn run_get(handler: &ToolHandler, args: &AgentArgs, cmd_args: &GetArgs) -> Result<String> {
955 let tool_args = json!({
956 "task": cmd_args.task_id,
957 "format": if args.format == CliOutputFormat::Json { "json" } else { "markdown" }
958 });
959
960 let result = tasks::get(&handler.db, args.format.into(), tool_args)?;
961 Ok(format_output(result, args.format))
962}
963
964fn run_claim(handler: &ToolHandler, args: &AgentArgs, cmd_args: &ClaimArgs) -> Result<String> {
965 let workflow = handler.get_workflow_for_worker(&cmd_args.worker_id);
967
968 let tool_args = json!({
969 "worker_id": &cmd_args.worker_id,
970 "task": cmd_args.task_id,
971 "force": cmd_args.force
972 });
973
974 let result = claiming::claim(&handler.db, &handler.config, &workflow, tool_args)?;
975 Ok(format_claim_output(result, args.format))
976}
977
978fn run_update(handler: &ToolHandler, args: &AgentArgs, cmd_args: &UpdateArgs) -> Result<String> {
979 let workflow = handler.get_workflow_for_worker(&cmd_args.worker_id);
981
982 let mut tool_args = json!({
983 "worker_id": &cmd_args.worker_id,
984 "task": cmd_args.task_id,
985 "force": cmd_args.force
986 });
987
988 if let Some(ref status) = cmd_args.status {
989 tool_args["status"] = json!(status);
990 }
991 if let Some(ref title) = cmd_args.title {
992 tool_args["title"] = json!(title);
993 }
994 if let Some(ref description) = cmd_args.description {
995 tool_args["description"] = json!(description);
996 }
997 if let Some(ref reason) = cmd_args.reason {
998 tool_args["reason"] = json!(reason);
999 }
1000
1001 let result = tasks::update(
1002 tasks::UpdateOptions {
1003 db: &handler.db,
1004 config: &handler.config,
1005 workflows: &workflow,
1006 },
1007 tool_args,
1008 )?;
1009 Ok(format_update_output(result, args.format))
1010}
1011
1012fn run_thinking(
1013 handler: &ToolHandler,
1014 args: &AgentArgs,
1015 cmd_args: &ThinkingArgs,
1016) -> Result<String> {
1017 let mut tool_args = json!({
1018 "agent": &cmd_args.worker_id,
1019 "thought": cmd_args.message
1020 });
1021
1022 if !cmd_args.tasks.is_empty() {
1023 tool_args["tasks"] = json!(cmd_args.tasks);
1024 }
1025
1026 let states_config: StatesConfig = handler.config.workflows.as_ref().into();
1027 let result = tracking::thinking(&handler.db, &states_config, tool_args)?;
1028 Ok(format_json_output(result, args.format))
1029}
1030
1031fn run_attach(handler: &ToolHandler, args: &AgentArgs, cmd_args: &AttachArgs) -> Result<String> {
1032 let content = if let Some(ref content) = cmd_args.content {
1034 content.clone()
1035 } else if let Some(ref file_path) = cmd_args.file {
1036 std::fs::read_to_string(file_path)
1037 .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path.display(), e))?
1038 } else {
1039 return Err(anyhow::anyhow!(
1040 "Either --content or --file must be provided"
1041 ));
1042 };
1043
1044 let mut tool_args = json!({
1045 "agent": &cmd_args.worker_id,
1046 "task": cmd_args.task_id,
1047 "type": cmd_args.r#type,
1048 "content": content
1049 });
1050
1051 if let Some(ref name) = cmd_args.name {
1052 tool_args["name"] = json!(name);
1053 }
1054
1055 let result = attachments::attach(
1056 &handler.db,
1057 &handler.media_dir,
1058 &handler.config.attachments,
1059 tool_args,
1060 )?;
1061 Ok(format_json_output(result, args.format))
1062}
1063
1064fn run_list_agents(
1065 handler: &ToolHandler,
1066 args: &AgentArgs,
1067 cmd_args: &ListAgentsArgs,
1068) -> Result<String> {
1069 let states_config: StatesConfig = handler.config.workflows.as_ref().into();
1070
1071 let mut tool_args = json!({
1072 "format": if args.format == CliOutputFormat::Json { "json" } else { "markdown" }
1073 });
1074
1075 if !cmd_args.tags.is_empty() {
1076 tool_args["tags"] = json!(cmd_args.tags);
1077 }
1078 if let Some(ref file) = cmd_args.file {
1079 tool_args["file"] = json!(file);
1080 }
1081 if let Some(ref task) = cmd_args.task {
1082 tool_args["task"] = json!(task);
1083 }
1084
1085 let result = agents::list_agents(&handler.db, &states_config, args.format.into(), tool_args)?;
1086 Ok(format_output(result, args.format))
1087}
1088
1089fn run_prompts(handler: &ToolHandler, args: &AgentArgs, cmd_args: &PromptsArgs) -> Result<String> {
1090 let workflows = &handler.config.workflows;
1091
1092 if let Some(ref advisory_topic) = cmd_args.advisory {
1094 let mut tool_args = json!({});
1095 if !advisory_topic.is_empty() {
1096 tool_args["topic"] = json!(advisory_topic);
1097 }
1098 if let Some(ref task_id) = cmd_args.task {
1099 tool_args["task"] = json!(task_id);
1100 }
1101 if let Some(ref wid) = args.worker_id {
1102 tool_args["worker_id"] = json!(wid);
1103 }
1104
1105 let result = advisories::get_advisory(&handler.db, workflows, tool_args)?;
1106
1107 if args.format == CliOutputFormat::Json {
1108 return Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()));
1109 }
1110
1111 if advisory_topic.is_empty() {
1113 let mut out = String::from("### Advisories\n\n");
1115 if let Some(topics) = result.get("advisories").and_then(|v| v.as_array()) {
1116 for entry in topics {
1117 let name = entry.get("topic").and_then(|v| v.as_str()).unwrap_or("?");
1118 let relevant = entry
1119 .get("relevant")
1120 .and_then(|v| v.as_bool())
1121 .unwrap_or(false);
1122 let marker = if relevant { " *" } else { "" };
1123 out.push_str(&format!("- `{}`{}\n", name, marker));
1124 }
1125 if let Some(count) = result.get("count").and_then(|v| v.as_i64()) {
1126 out.push_str(&format!(
1127 "\n{} advisories (* = relevant to current context)\n",
1128 count
1129 ));
1130 }
1131 }
1132 return Ok(out);
1133 } else {
1134 let mut out = String::new();
1136 if let Some(topic) = result.get("topic").and_then(|v| v.as_str()) {
1137 out.push_str(&format!("### Advisory: {}\n\n", topic));
1138 }
1139 if let Some(content) = result.get("content").and_then(|v| v.as_str()) {
1140 out.push_str(content);
1141 out.push('\n');
1142 }
1143 return Ok(out);
1144 }
1145 }
1146
1147 if cmd_args.status.is_some() || cmd_args.phase.is_some() {
1149 let states_config: StatesConfig = workflows.as_ref().into();
1150 let phases_config: PhasesConfig = workflows.as_ref().into();
1151
1152 let target_status = cmd_args.status.as_deref().unwrap_or(&states_config.initial);
1153 let target_phase = cmd_args.phase.as_deref();
1154
1155 let ctx = prompt_system::PromptContext::new(
1157 target_status,
1158 target_phase,
1159 &states_config,
1160 &phases_config,
1161 );
1162
1163 let attributed_list = prompt_system::get_transition_prompts_attributed(
1165 "",
1166 None,
1167 target_status,
1168 target_phase,
1169 workflows,
1170 &ctx,
1171 );
1172
1173 if args.format == CliOutputFormat::Json {
1174 let prompt_objects: Vec<serde_json::Value> = attributed_list
1175 .iter()
1176 .map(|p| {
1177 serde_json::json!({
1178 "text": p.text,
1179 "source": p.source,
1180 })
1181 })
1182 .collect();
1183 return Ok(serde_json::to_string_pretty(&serde_json::json!({
1184 "status": target_status,
1185 "phase": target_phase,
1186 "prompts": prompt_objects,
1187 }))?);
1188 }
1189
1190 let mut out = format!("### Prompts for entering `{}`", target_status);
1191 if let Some(phase) = target_phase {
1192 out.push_str(&format!(" (phase: `{}`)", phase));
1193 }
1194 out.push_str("\n\n");
1195
1196 if attributed_list.is_empty() {
1197 out.push_str("_(no prompts configured for this transition)_\n");
1198 } else {
1199 for (i, prompt) in attributed_list.iter().enumerate() {
1200 for line in prompt.text.lines() {
1201 out.push_str(&format!("> {}\n", line));
1202 }
1203 if i + 1 < attributed_list.len() {
1204 out.push_str("\n---\n\n");
1205 }
1206 }
1207 }
1208
1209 return Ok(out);
1210 }
1211
1212 let triggers = prompt_system::list_available_prompts(workflows);
1214
1215 if args.format == CliOutputFormat::Json {
1216 return Ok(serde_json::to_string_pretty(&json!({
1217 "triggers": triggers,
1218 "count": triggers.len(),
1219 }))?);
1220 }
1221
1222 let mut out = String::from("### Prompt Triggers\n\n");
1223
1224 let mut enter_state: Vec<&str> = Vec::new();
1226 let mut exit_state: Vec<&str> = Vec::new();
1227 let mut enter_phase: Vec<&str> = Vec::new();
1228 let mut exit_phase: Vec<&str> = Vec::new();
1229 let mut combos: Vec<&str> = Vec::new();
1230
1231 for t in &triggers {
1232 if t.contains('~') && t.contains('%') {
1233 combos.push(t);
1234 } else if t.starts_with("enter~") {
1235 enter_state.push(t);
1236 } else if t.starts_with("exit~") {
1237 exit_state.push(t);
1238 } else if t.starts_with("enter%") {
1239 enter_phase.push(t);
1240 } else if t.starts_with("exit%") {
1241 exit_phase.push(t);
1242 }
1243 }
1244
1245 if !enter_state.is_empty() {
1246 out.push_str("**Enter state:**\n");
1247 for t in &enter_state {
1248 out.push_str(&format!(" - `{}`\n", t));
1249 }
1250 }
1251 if !exit_state.is_empty() {
1252 out.push_str("**Exit state:**\n");
1253 for t in &exit_state {
1254 out.push_str(&format!(" - `{}`\n", t));
1255 }
1256 }
1257 if !enter_phase.is_empty() {
1258 out.push_str("**Enter phase:**\n");
1259 for t in &enter_phase {
1260 out.push_str(&format!(" - `{}`\n", t));
1261 }
1262 }
1263 if !exit_phase.is_empty() {
1264 out.push_str("**Exit phase:**\n");
1265 for t in &exit_phase {
1266 out.push_str(&format!(" - `{}`\n", t));
1267 }
1268 }
1269 if !combos.is_empty() {
1270 out.push_str("**State+phase combos:**\n");
1271 for t in &combos {
1272 out.push_str(&format!(" - `{}`\n", t));
1273 }
1274 }
1275
1276 out.push_str(&format!("\n{} triggers total\n", triggers.len()));
1277
1278 Ok(out)
1279}
1280
1281fn run_interactive(
1283 handler: &ToolHandler,
1284 args: &AgentArgs,
1285 cmd_args: &InteractiveArgs,
1286) -> ExitCode {
1287 use std::io::{BufRead, Write};
1288
1289 let stdin = std::io::stdin();
1290 let mut stdout = std::io::stdout();
1291
1292 if cmd_args.stdin {
1293 let reader = stdin.lock();
1295 for line in reader.lines() {
1296 match line {
1297 Ok(cmd) => {
1298 let cmd = cmd.trim();
1299 if cmd.is_empty() || cmd.starts_with('#') {
1300 continue;
1301 }
1302 if let Err(code) = execute_line_command(handler, args, cmd) {
1303 return code;
1304 }
1305 }
1306 Err(e) => {
1307 eprintln!("Error reading input: {}", e);
1308 return ExitCode::from(exit_codes::GENERAL_ERROR);
1309 }
1310 }
1311 }
1312 } else {
1313 println!("task-graph agent interactive mode. Type 'help' for commands, 'exit' to quit.");
1315 if let Some(ref worker_id) = args.worker_id {
1316 println!("Worker ID: {}", worker_id);
1317 }
1318 println!();
1319
1320 loop {
1321 print!("> ");
1322 let _ = stdout.flush();
1323
1324 let mut input = String::new();
1325 match stdin.read_line(&mut input) {
1326 Ok(0) => break, Ok(_) => {
1328 let cmd = input.trim();
1329 if cmd.is_empty() {
1330 continue;
1331 }
1332 if cmd == "exit" || cmd == "quit" || cmd == "q" {
1333 break;
1334 }
1335 if cmd == "help" || cmd == "?" {
1336 print_interactive_help();
1337 continue;
1338 }
1339 if execute_line_command(handler, args, cmd).is_err() {
1340 }
1342 }
1343 Err(e) => {
1344 eprintln!("Error reading input: {}", e);
1345 break;
1346 }
1347 }
1348 }
1349 }
1350
1351 ExitCode::from(exit_codes::SUCCESS)
1352}
1353
1354fn run_batch(handler: &ToolHandler, args: &AgentArgs, cmd_args: &BatchArgs) -> ExitCode {
1356 use std::io::BufRead;
1357
1358 let file = match std::fs::File::open(&cmd_args.file) {
1359 Ok(f) => f,
1360 Err(e) => {
1361 eprintln!("Error opening file '{}': {}", cmd_args.file.display(), e);
1362 return ExitCode::from(exit_codes::GENERAL_ERROR);
1363 }
1364 };
1365
1366 let reader = std::io::BufReader::new(file);
1367 let mut line_num = 0;
1368 let mut had_errors = false;
1369
1370 for line in reader.lines() {
1371 line_num += 1;
1372 match line {
1373 Ok(cmd) => {
1374 let cmd = cmd.trim();
1375 if cmd.is_empty() || cmd.starts_with('#') {
1376 continue;
1377 }
1378 eprintln!("[{}] > {}", line_num, cmd);
1379 if execute_line_command(handler, args, cmd).is_err() {
1380 had_errors = true;
1381 if !cmd_args.keep_going {
1382 return ExitCode::from(exit_codes::GENERAL_ERROR);
1383 }
1384 }
1385 }
1386 Err(e) => {
1387 eprintln!("Error reading line {}: {}", line_num, e);
1388 return ExitCode::from(exit_codes::GENERAL_ERROR);
1389 }
1390 }
1391 }
1392
1393 if had_errors {
1394 ExitCode::from(exit_codes::GENERAL_ERROR)
1395 } else {
1396 ExitCode::from(exit_codes::SUCCESS)
1397 }
1398}
1399
1400fn execute_line_command(
1403 handler: &ToolHandler,
1404 args: &AgentArgs,
1405 cmd: &str,
1406) -> Result<(), ExitCode> {
1407 let parts: Vec<&str> = cmd.split_whitespace().collect();
1409 if parts.is_empty() {
1410 return Ok(());
1411 }
1412
1413 let subcommand = parts[0];
1414 let subargs = &parts[1..];
1415
1416 let require_worker_id = || -> Result<String, ExitCode> {
1418 args.worker_id.clone().ok_or_else(|| {
1419 eprintln!(
1420 "Error: Worker ID required. Use --worker-id flag with 'interactive' command."
1421 );
1422 ExitCode::from(exit_codes::INVALID_ARGUMENTS)
1423 })
1424 };
1425 let result: Result<String> = match subcommand {
1429 "ls" | "list-tasks" | "list_tasks" => {
1430 let mut list_args = ListTasksArgs {
1432 ready: false,
1433 blocked: false,
1434 status: vec![],
1435 parent: None,
1436 limit: None,
1437 offset: None,
1438 };
1439 let mut i = 0;
1440 while i < subargs.len() {
1441 match subargs[i] {
1442 "--ready" => list_args.ready = true,
1443 "--blocked" => list_args.blocked = true,
1444 "--status" if i + 1 < subargs.len() => {
1445 i += 1;
1446 list_args.status = subargs[i].split(',').map(String::from).collect();
1447 }
1448 "--parent" if i + 1 < subargs.len() => {
1449 i += 1;
1450 list_args.parent = Some(subargs[i].to_string());
1451 }
1452 "--limit" if i + 1 < subargs.len() => {
1453 i += 1;
1454 list_args.limit = subargs[i].parse().ok();
1455 }
1456 "--offset" if i + 1 < subargs.len() => {
1457 i += 1;
1458 list_args.offset = subargs[i].parse().ok();
1459 }
1460 _ => {}
1461 }
1462 i += 1;
1463 }
1464 run_list_tasks(handler, args, &list_args)
1465 }
1466
1467 "get" => {
1468 if subargs.is_empty() {
1469 Err(anyhow::anyhow!("Usage: get <task-id>"))
1470 } else {
1471 let get_args = GetArgs {
1472 task_id: subargs[0].to_string(),
1473 };
1474 run_get(handler, args, &get_args)
1475 }
1476 }
1477
1478 "claim" => {
1479 if subargs.is_empty() {
1480 Err(anyhow::anyhow!("Usage: claim <task-id> [--force]"))
1481 } else {
1482 let claim_args = ClaimArgs {
1483 worker_id: require_worker_id()?,
1484 task_id: subargs[0].to_string(),
1485 force: subargs.contains(&"--force"),
1486 };
1487 run_claim(handler, args, &claim_args)
1488 }
1489 }
1490
1491 "update" => {
1492 if subargs.is_empty() {
1493 Err(anyhow::anyhow!(
1494 "Usage: update <task-id> [--status STATUS] [--title TITLE] [--description DESC] [--reason REASON]"
1495 ))
1496 } else {
1497 let mut update_args = UpdateArgs {
1498 worker_id: require_worker_id()?,
1499 task_id: subargs[0].to_string(),
1500 status: None,
1501 title: None,
1502 description: None,
1503 reason: None,
1504 force: false,
1505 };
1506 let mut i = 1;
1507 while i < subargs.len() {
1508 match subargs[i] {
1509 "--status" if i + 1 < subargs.len() => {
1510 i += 1;
1511 update_args.status = Some(subargs[i].to_string());
1512 }
1513 "--title" if i + 1 < subargs.len() => {
1514 i += 1;
1515 update_args.title = Some(subargs[i].to_string());
1516 }
1517 "--description" if i + 1 < subargs.len() => {
1518 i += 1;
1519 update_args.description = Some(subargs[i].to_string());
1520 }
1521 "--reason" if i + 1 < subargs.len() => {
1522 i += 1;
1523 update_args.reason = Some(subargs[i].to_string());
1524 }
1525 "--force" => update_args.force = true,
1526 _ => {}
1527 }
1528 i += 1;
1529 }
1530 run_update(handler, args, &update_args)
1531 }
1532 }
1533
1534 "thinking" => {
1535 if subargs.is_empty() {
1536 Err(anyhow::anyhow!(
1537 "Usage: thinking <message> [--tasks TASK1,TASK2]"
1538 ))
1539 } else {
1540 let mut tasks = vec![];
1541 let mut message_parts = vec![];
1542 let mut i = 0;
1543 while i < subargs.len() {
1544 if subargs[i] == "--tasks" && i + 1 < subargs.len() {
1545 i += 1;
1546 tasks = subargs[i].split(',').map(String::from).collect();
1547 } else {
1548 message_parts.push(subargs[i]);
1549 }
1550 i += 1;
1551 }
1552 let thinking_args = ThinkingArgs {
1553 worker_id: require_worker_id()?,
1554 message: message_parts.join(" "),
1555 tasks,
1556 };
1557 run_thinking(handler, args, &thinking_args)
1558 }
1559 }
1560
1561 "list-agents" | "list_agents" | "agents" => {
1562 let mut list_args = ListAgentsArgs {
1563 tags: vec![],
1564 file: None,
1565 task: None,
1566 };
1567 let mut i = 0;
1568 while i < subargs.len() {
1569 match subargs[i] {
1570 "--tags" if i + 1 < subargs.len() => {
1571 i += 1;
1572 list_args.tags = subargs[i].split(',').map(String::from).collect();
1573 }
1574 "--file" if i + 1 < subargs.len() => {
1575 i += 1;
1576 list_args.file = Some(subargs[i].to_string());
1577 }
1578 "--task" if i + 1 < subargs.len() => {
1579 i += 1;
1580 list_args.task = Some(subargs[i].to_string());
1581 }
1582 _ => {}
1583 }
1584 i += 1;
1585 }
1586 run_list_agents(handler, args, &list_args)
1587 }
1588
1589 "prompts" => {
1590 let mut prompts_args = PromptsArgs {
1591 status: None,
1592 phase: None,
1593 advisory: None,
1594 task: None,
1595 };
1596 let mut i = 0;
1597 while i < subargs.len() {
1598 match subargs[i] {
1599 "--status" if i + 1 < subargs.len() => {
1600 i += 1;
1601 prompts_args.status = Some(subargs[i].to_string());
1602 }
1603 "--phase" if i + 1 < subargs.len() => {
1604 i += 1;
1605 prompts_args.phase = Some(subargs[i].to_string());
1606 }
1607 "--advisory" => {
1608 if i + 1 < subargs.len() && !subargs[i + 1].starts_with("--") {
1609 i += 1;
1610 prompts_args.advisory = Some(subargs[i].to_string());
1611 } else {
1612 prompts_args.advisory = Some(String::new());
1613 }
1614 }
1615 "--task" if i + 1 < subargs.len() => {
1616 i += 1;
1617 prompts_args.task = Some(subargs[i].to_string());
1618 }
1619 _ => {}
1620 }
1621 i += 1;
1622 }
1623 run_prompts(handler, args, &prompts_args)
1624 }
1625
1626 "connect" => {
1627 let mut connect_args = ConnectArgs {
1629 worker_id: args.worker_id.clone(),
1630 tags: vec![],
1631 workflow: None,
1632 overlays: vec![],
1633 force: false,
1634 };
1635 let mut i = 0;
1636 while i < subargs.len() {
1637 match subargs[i] {
1638 "--tags" if i + 1 < subargs.len() => {
1639 i += 1;
1640 connect_args.tags = subargs[i].split(',').map(String::from).collect();
1641 }
1642 "--workflow" if i + 1 < subargs.len() => {
1643 i += 1;
1644 connect_args.workflow = Some(subargs[i].to_string());
1645 }
1646 "--overlays" if i + 1 < subargs.len() => {
1647 i += 1;
1648 connect_args.overlays = subargs[i].split(',').map(String::from).collect();
1649 }
1650 "--force" => connect_args.force = true,
1651 _ => {}
1652 }
1653 i += 1;
1654 }
1655 run_connect(handler, args, &connect_args)
1656 }
1657
1658 "disconnect" => {
1659 let disconnect_args = DisconnectArgs {
1660 worker_id: require_worker_id()?,
1661 final_status: None,
1662 };
1663 let mut disconnect_args = disconnect_args;
1665 let mut i = 0;
1666 while i < subargs.len() {
1667 if subargs[i] == "--final-status" && i + 1 < subargs.len() {
1668 i += 1;
1669 disconnect_args.final_status = Some(subargs[i].to_string());
1670 }
1671 i += 1;
1672 }
1673 run_disconnect(handler, args, &disconnect_args)
1674 }
1675
1676 _ => Err(anyhow::anyhow!(
1677 "Unknown command: {}. Type 'help' for available commands.",
1678 subcommand
1679 )),
1680 };
1681
1682 match result {
1683 Ok(output) => {
1684 println!("{}", output);
1685 Ok(())
1686 }
1687 Err(e) => {
1688 eprintln!("Error: {}", e);
1689 Err(ExitCode::from(error_to_exit_code(&e)))
1690 }
1691 }
1692}
1693
1694fn print_interactive_help() {
1696 println!(
1697 r#"Available commands (worker_id from --worker-id flag):
1698 ls, list-tasks Query tasks (--ready, --blocked, --status S, --parent P, --limit N, --offset N)
1699 get <task-id> Get task details
1700 claim <task-id> Claim a task (--force) [requires --worker-id]
1701 update <task-id> Update task (--status S, --title T, --reason R, --force) [requires --worker-id]
1702 thinking <msg> Broadcast status (--tasks T1,T2) [requires --worker-id]
1703 prompts Query prompts (--status S, --phase P, --advisory [TOPIC], --task T)
1704 agents List connected workers (--tags T, --file F, --task T)
1705 connect Register as worker (--tags T, --workflow W, --overlays O, --force)
1706 disconnect Unregister (--final-status S) [requires --worker-id]
1707 help, ? Show this help
1708 exit, quit, q Exit interactive mode
1709"#
1710 );
1711}
1712
1713#[cfg(test)]
1714mod tests {
1715 use super::*;
1716 use clap::Parser;
1717
1718 #[derive(Parser)]
1720 struct TestCli {
1721 #[command(subcommand)]
1722 command: TestCommand,
1723 }
1724
1725 #[derive(Subcommand)]
1726 enum TestCommand {
1727 Agent(AgentArgs),
1728 }
1729
1730 fn parse_agent(args: &[&str]) -> AgentArgs {
1732 let mut full_args = vec!["test", "agent"];
1733 full_args.extend_from_slice(args);
1734 let cli = TestCli::try_parse_from(full_args).unwrap();
1735 let TestCommand::Agent(agent_args) = cli.command;
1736 agent_args
1737 }
1738
1739 #[test]
1742 fn test_parse_connect_no_worker_id() {
1743 let a = parse_agent(&["connect"]);
1744 let AgentCommand::Connect(c) = a.command else {
1745 panic!()
1746 };
1747 assert_eq!(c.worker_id, None);
1748 assert!(c.tags.is_empty());
1749 assert!(!c.force);
1750 }
1751
1752 #[test]
1753 fn test_parse_connect_with_worker_id() {
1754 let a = parse_agent(&["connect", "my-worker"]);
1755 let AgentCommand::Connect(c) = a.command else {
1756 panic!()
1757 };
1758 assert_eq!(c.worker_id, Some("my-worker".to_string()));
1759 }
1760
1761 #[test]
1762 fn test_parse_connect_with_tags_workflow_overlays() {
1763 let a = parse_agent(&[
1764 "connect",
1765 "--tags",
1766 "build,test",
1767 "--workflow",
1768 "swarm",
1769 "--overlays",
1770 "reasoning,patch",
1771 "--force",
1772 ]);
1773 let AgentCommand::Connect(c) = a.command else {
1774 panic!()
1775 };
1776 assert_eq!(c.tags, vec!["build", "test"]);
1777 assert_eq!(c.workflow, Some("swarm".to_string()));
1778 assert_eq!(c.overlays, vec!["reasoning", "patch"]);
1779 assert!(c.force);
1780 }
1781
1782 #[test]
1785 fn test_parse_disconnect() {
1786 let a = parse_agent(&["disconnect", "worker-1"]);
1787 let AgentCommand::Disconnect(d) = a.command else {
1788 panic!()
1789 };
1790 assert_eq!(d.worker_id, "worker-1");
1791 assert_eq!(d.final_status, None);
1792 }
1793
1794 #[test]
1795 fn test_parse_disconnect_with_final_status() {
1796 let a = parse_agent(&["disconnect", "w1", "--final-status", "pending"]);
1797 let AgentCommand::Disconnect(d) = a.command else {
1798 panic!()
1799 };
1800 assert_eq!(d.worker_id, "w1");
1801 assert_eq!(d.final_status, Some("pending".to_string()));
1802 }
1803
1804 #[test]
1805 fn test_parse_disconnect_missing_worker_id() {
1806 let full = vec!["test", "agent", "disconnect"];
1807 let result = TestCli::try_parse_from(full);
1808 assert!(result.is_err(), "disconnect without worker_id should fail");
1809 }
1810
1811 #[test]
1814 fn test_parse_list_tasks_alias_ls() {
1815 let a = parse_agent(&["ls", "--ready"]);
1816 let AgentCommand::ListTasks(l) = a.command else {
1817 panic!()
1818 };
1819 assert!(l.ready);
1820 assert!(!l.blocked);
1821 }
1822
1823 #[test]
1824 fn test_parse_list_tasks_full_filters() {
1825 let a = parse_agent(&[
1826 "list-tasks",
1827 "--ready",
1828 "--blocked",
1829 "--status",
1830 "open,in_progress",
1831 "--parent",
1832 "root-1",
1833 "--limit",
1834 "10",
1835 "--offset",
1836 "5",
1837 ]);
1838 let AgentCommand::ListTasks(l) = a.command else {
1839 panic!()
1840 };
1841 assert!(l.ready);
1842 assert!(l.blocked);
1843 assert_eq!(l.status, vec!["open", "in_progress"]);
1844 assert_eq!(l.parent, Some("root-1".to_string()));
1845 assert_eq!(l.limit, Some(10));
1846 assert_eq!(l.offset, Some(5));
1847 }
1848
1849 #[test]
1850 fn test_parse_list_tasks_defaults() {
1851 let a = parse_agent(&["list-tasks"]);
1852 let AgentCommand::ListTasks(l) = a.command else {
1853 panic!()
1854 };
1855 assert!(!l.ready);
1856 assert!(!l.blocked);
1857 assert!(l.status.is_empty());
1858 assert_eq!(l.parent, None);
1859 assert_eq!(l.limit, None);
1860 assert_eq!(l.offset, None);
1861 }
1862
1863 #[test]
1866 fn test_parse_get() {
1867 let a = parse_agent(&["get", "task-abc"]);
1868 let AgentCommand::Get(g) = a.command else {
1869 panic!()
1870 };
1871 assert_eq!(g.task_id, "task-abc");
1872 }
1873
1874 #[test]
1875 fn test_parse_get_missing_task_id() {
1876 let result = TestCli::try_parse_from(["test", "agent", "get"]);
1877 assert!(result.is_err(), "get without task_id should fail");
1878 }
1879
1880 #[test]
1883 fn test_parse_claim_basic() {
1884 let a = parse_agent(&["claim", "worker-1", "task-123"]);
1885 let AgentCommand::Claim(c) = a.command else {
1886 panic!()
1887 };
1888 assert_eq!(c.worker_id, "worker-1");
1889 assert_eq!(c.task_id, "task-123");
1890 assert!(!c.force);
1891 }
1892
1893 #[test]
1894 fn test_parse_claim_force() {
1895 let a = parse_agent(&["claim", "w1", "t1", "--force"]);
1896 let AgentCommand::Claim(c) = a.command else {
1897 panic!()
1898 };
1899 assert!(c.force);
1900 }
1901
1902 #[test]
1903 fn test_parse_claim_missing_args() {
1904 let result = TestCli::try_parse_from(["test", "agent", "claim"]);
1906 assert!(result.is_err());
1907
1908 let result = TestCli::try_parse_from(["test", "agent", "claim", "w1"]);
1910 assert!(result.is_err());
1911 }
1912
1913 #[test]
1916 fn test_parse_update_all_fields() {
1917 let a = parse_agent(&[
1918 "update",
1919 "w1",
1920 "task-1",
1921 "--status",
1922 "completed",
1923 "--title",
1924 "New title",
1925 "--description",
1926 "New desc",
1927 "--reason",
1928 "Done",
1929 "--force",
1930 ]);
1931 let AgentCommand::Update(u) = a.command else {
1932 panic!()
1933 };
1934 assert_eq!(u.worker_id, "w1");
1935 assert_eq!(u.task_id, "task-1");
1936 assert_eq!(u.status, Some("completed".to_string()));
1937 assert_eq!(u.title, Some("New title".to_string()));
1938 assert_eq!(u.description, Some("New desc".to_string()));
1939 assert_eq!(u.reason, Some("Done".to_string()));
1940 assert!(u.force);
1941 }
1942
1943 #[test]
1944 fn test_parse_update_minimal() {
1945 let a = parse_agent(&["update", "w1", "task-1"]);
1946 let AgentCommand::Update(u) = a.command else {
1947 panic!()
1948 };
1949 assert_eq!(u.worker_id, "w1");
1950 assert_eq!(u.task_id, "task-1");
1951 assert_eq!(u.status, None);
1952 assert_eq!(u.title, None);
1953 assert!(!u.force);
1954 }
1955
1956 #[test]
1959 fn test_parse_thinking() {
1960 let a = parse_agent(&["thinking", "w1", "Analyzing code"]);
1961 let AgentCommand::Thinking(t) = a.command else {
1962 panic!()
1963 };
1964 assert_eq!(t.worker_id, "w1");
1965 assert_eq!(t.message, "Analyzing code");
1966 assert!(t.tasks.is_empty());
1967 }
1968
1969 #[test]
1970 fn test_parse_thinking_with_tasks() {
1971 let a = parse_agent(&["thinking", "w1", "Working", "--tasks", "t1,t2,t3"]);
1972 let AgentCommand::Thinking(t) = a.command else {
1973 panic!()
1974 };
1975 assert_eq!(t.tasks, vec!["t1", "t2", "t3"]);
1976 }
1977
1978 #[test]
1981 fn test_parse_attach_with_content() {
1982 let a = parse_agent(&["attach", "w1", "task-1", "-t", "note", "-c", "My note"]);
1983 let AgentCommand::Attach(att) = a.command else {
1984 panic!()
1985 };
1986 assert_eq!(att.worker_id, "w1");
1987 assert_eq!(att.task_id, "task-1");
1988 assert_eq!(att.r#type, "note");
1989 assert_eq!(att.content, Some("My note".to_string()));
1990 assert_eq!(att.file, None);
1991 }
1992
1993 #[test]
1994 fn test_parse_attach_with_file() {
1995 let a = parse_agent(&["attach", "w1", "task-1", "-t", "log", "--file", "out.log"]);
1996 let AgentCommand::Attach(att) = a.command else {
1997 panic!()
1998 };
1999 assert_eq!(att.file, Some(PathBuf::from("out.log")));
2000 assert_eq!(att.content, None);
2001 }
2002
2003 #[test]
2004 fn test_parse_attach_content_and_file_conflict() {
2005 let result = TestCli::try_parse_from([
2006 "test", "agent", "attach", "w1", "t1", "-t", "note", "-c", "text", "--file", "f.txt",
2007 ]);
2008 assert!(result.is_err(), "--content and --file should conflict");
2009 }
2010
2011 #[test]
2014 fn test_parse_list_agents_defaults() {
2015 let a = parse_agent(&["list-agents"]);
2016 let AgentCommand::ListAgents(la) = a.command else {
2017 panic!()
2018 };
2019 assert!(la.tags.is_empty());
2020 assert_eq!(la.file, None);
2021 assert_eq!(la.task, None);
2022 }
2023
2024 #[test]
2025 fn test_parse_list_agents_with_filters() {
2026 let a = parse_agent(&["list-agents", "--tags", "build", "--task", "t1"]);
2027 let AgentCommand::ListAgents(la) = a.command else {
2028 panic!()
2029 };
2030 assert_eq!(la.tags, vec!["build"]);
2031 assert_eq!(la.task, Some("t1".to_string()));
2032 }
2033
2034 #[test]
2037 fn test_parse_interactive() {
2038 let a = parse_agent(&["interactive"]);
2039 let AgentCommand::Interactive(i) = a.command else {
2040 panic!()
2041 };
2042 assert!(!i.stdin);
2043 }
2044
2045 #[test]
2046 fn test_parse_interactive_stdin() {
2047 let a = parse_agent(&["interactive", "--stdin"]);
2048 let AgentCommand::Interactive(i) = a.command else {
2049 panic!()
2050 };
2051 assert!(i.stdin);
2052 }
2053
2054 #[test]
2055 fn test_parse_repl_alias() {
2056 let a = parse_agent(&["repl"]);
2057 assert!(matches!(a.command, AgentCommand::Interactive(_)));
2058 }
2059
2060 #[test]
2061 fn test_parse_batch() {
2062 let a = parse_agent(&["batch", "commands.txt"]);
2063 let AgentCommand::Batch(b) = a.command else {
2064 panic!()
2065 };
2066 assert_eq!(b.file, PathBuf::from("commands.txt"));
2067 assert!(!b.keep_going);
2068 }
2069
2070 #[test]
2071 fn test_parse_batch_keep_going() {
2072 let a = parse_agent(&["batch", "-k", "cmds.txt"]);
2073 let AgentCommand::Batch(b) = a.command else {
2074 panic!()
2075 };
2076 assert!(b.keep_going);
2077 }
2078
2079 #[test]
2082 fn test_parse_format_json() {
2083 let a = parse_agent(&["--format", "json", "list-tasks"]);
2084 assert_eq!(a.format, CliOutputFormat::Json);
2085 }
2086
2087 #[test]
2088 fn test_parse_format_default_is_markdown() {
2089 let a = parse_agent(&["list-tasks"]);
2090 assert_eq!(a.format, CliOutputFormat::Markdown);
2091 }
2092
2093 #[test]
2094 fn test_parse_global_worker_id() {
2095 let a = parse_agent(&["--worker-id", "global-w", "list-tasks"]);
2096 assert_eq!(a.worker_id, Some("global-w".to_string()));
2097 }
2098
2099 #[test]
2100 fn test_parse_no_global_worker_id() {
2101 let a = parse_agent(&["list-tasks"]);
2102 assert_eq!(a.worker_id, None);
2103 }
2104
2105 #[test]
2108 fn test_cli_format_to_output_format() {
2109 assert!(matches!(
2110 OutputFormat::from(CliOutputFormat::Markdown),
2111 OutputFormat::Markdown
2112 ));
2113 assert!(matches!(
2114 OutputFormat::from(CliOutputFormat::Json),
2115 OutputFormat::Json
2116 ));
2117 }
2118
2119 #[test]
2123 fn test_error_to_exit_code_task_not_found() {
2124 let err = anyhow::anyhow!(crate::error::ToolError::task_not_found("abc"));
2125 assert_eq!(error_to_exit_code(&err), exit_codes::TASK_NOT_FOUND);
2126 }
2127
2128 #[test]
2129 fn test_error_to_exit_code_agent_not_found() {
2130 let err = anyhow::anyhow!(crate::error::ToolError::agent_not_found("w1"));
2131 assert_eq!(error_to_exit_code(&err), exit_codes::WORKER_NOT_FOUND);
2132 }
2133
2134 #[test]
2135 fn test_error_to_exit_code_already_claimed() {
2136 let err = anyhow::anyhow!(crate::error::ToolError::already_claimed("t1", "w2"));
2137 assert_eq!(error_to_exit_code(&err), exit_codes::CLAIM_FAILED);
2138 }
2139
2140 #[test]
2141 fn test_error_to_exit_code_not_owner() {
2142 let err = anyhow::anyhow!(crate::error::ToolError::not_owner("t1", "w1"));
2143 assert_eq!(error_to_exit_code(&err), exit_codes::PERMISSION_DENIED);
2144 }
2145
2146 #[test]
2147 fn test_error_to_exit_code_missing_field() {
2148 let err = anyhow::anyhow!(crate::error::ToolError::missing_field("worker_id"));
2149 assert_eq!(error_to_exit_code(&err), exit_codes::INVALID_ARGUMENTS);
2150 }
2151
2152 #[test]
2153 fn test_error_to_exit_code_invalid_value() {
2154 let err = anyhow::anyhow!(crate::error::ToolError::invalid_value("status", "bad"));
2155 assert_eq!(error_to_exit_code(&err), exit_codes::INVALID_ARGUMENTS);
2156 }
2157
2158 #[test]
2159 fn test_error_to_exit_code_generic() {
2160 let err = anyhow::anyhow!("some random error");
2161 assert_eq!(error_to_exit_code(&err), exit_codes::GENERAL_ERROR);
2162 }
2163
2164 #[test]
2165 fn test_error_to_exit_code_dependency_blocked() {
2166 let err = anyhow::anyhow!(crate::error::ToolError::deps_not_satisfied(&[
2167 "dep-1".to_string(),
2168 ]));
2169 assert_eq!(error_to_exit_code(&err), exit_codes::CLAIM_FAILED);
2170 }
2171
2172 #[test]
2175 fn test_format_json_output_json_mode() {
2176 let v = serde_json::json!({"status": "ok"});
2177 let out = format_json_output(v.clone(), CliOutputFormat::Json);
2178 assert_eq!(out, serde_json::to_string_pretty(&v).unwrap());
2179 }
2180
2181 #[test]
2182 fn test_format_json_output_markdown_mode() {
2183 let v = serde_json::json!({"status": "ok"});
2184 let out = format_json_output(v.clone(), CliOutputFormat::Markdown);
2185 assert!(out.contains("status"));
2187 }
2188
2189 #[test]
2190 fn test_format_output_raw_markdown() {
2191 let result = ToolResult::Raw("# Tasks\n- task-1".to_string());
2192 let out = format_output(result, CliOutputFormat::Markdown);
2193 assert_eq!(out, "# Tasks\n- task-1");
2194 }
2195
2196 #[test]
2197 fn test_format_output_raw_json() {
2198 let result = ToolResult::Raw("hello".to_string());
2199 let out = format_output(result, CliOutputFormat::Json);
2200 assert!(out.contains("\"output\""));
2201 assert!(out.contains("hello"));
2202 }
2203
2204 #[test]
2207 fn test_parse_prompts_no_args() {
2208 let a = parse_agent(&["prompts"]);
2209 let AgentCommand::Prompts(p) = a.command else {
2210 panic!()
2211 };
2212 assert_eq!(p.status, None);
2213 assert_eq!(p.phase, None);
2214 assert_eq!(p.advisory, None);
2215 assert_eq!(p.task, None);
2216 }
2217
2218 #[test]
2219 fn test_parse_prompts_with_status() {
2220 let a = parse_agent(&["prompts", "--status", "working"]);
2221 let AgentCommand::Prompts(p) = a.command else {
2222 panic!()
2223 };
2224 assert_eq!(p.status, Some("working".to_string()));
2225 }
2226
2227 #[test]
2228 fn test_parse_prompts_with_phase() {
2229 let a = parse_agent(&["prompts", "--phase", "implement"]);
2230 let AgentCommand::Prompts(p) = a.command else {
2231 panic!()
2232 };
2233 assert_eq!(p.phase, Some("implement".to_string()));
2234 }
2235
2236 #[test]
2237 fn test_parse_prompts_advisory_list() {
2238 let a = parse_agent(&["prompts", "--advisory"]);
2239 let AgentCommand::Prompts(p) = a.command else {
2240 panic!()
2241 };
2242 assert_eq!(p.advisory, Some(String::new()));
2243 }
2244
2245 #[test]
2246 fn test_parse_prompts_advisory_specific() {
2247 let a = parse_agent(&["prompts", "--advisory", "decompose-epic"]);
2248 let AgentCommand::Prompts(p) = a.command else {
2249 panic!()
2250 };
2251 assert_eq!(p.advisory, Some("decompose-epic".to_string()));
2252 }
2253
2254 #[test]
2255 fn test_parse_prompts_with_task() {
2256 let a = parse_agent(&["prompts", "--status", "working", "--task", "task-123"]);
2257 let AgentCommand::Prompts(p) = a.command else {
2258 panic!()
2259 };
2260 assert_eq!(p.status, Some("working".to_string()));
2261 assert_eq!(p.task, Some("task-123".to_string()));
2262 }
2263
2264 #[test]
2267 fn test_format_connect_output_json_mode() {
2268 let v = json!({
2269 "worker_id": "w1",
2270 "tags": ["build"],
2271 "config": { "states": ["pending", "working"], "initial_state": "pending" }
2272 });
2273 let out = format_connect_output(v.clone(), CliOutputFormat::Json);
2274 assert_eq!(out, serde_json::to_string_pretty(&v).unwrap());
2275 }
2276
2277 #[test]
2278 fn test_format_connect_output_markdown_mode() {
2279 let v = json!({
2280 "worker_id": "test-worker",
2281 "tags": ["build", "test"],
2282 "config": {
2283 "states": ["pending", "working", "completed"],
2284 "initial_state": "pending",
2285 "timed_states": ["working"],
2286 "terminal_states": ["completed"],
2287 "phases": ["implement", "test"]
2288 },
2289 "role": { "role": "worker", "description": "A worker role" },
2290 "role_prompts": ["You are actively working."],
2291 "paths": {
2292 "db_path": "tasks.db",
2293 "media_dir": "media",
2294 "log_dir": "logs"
2295 }
2296 });
2297 let out = format_connect_output(v, CliOutputFormat::Markdown);
2298 assert!(out.contains("**Worker ID:** `test-worker`"));
2299 assert!(out.contains("**Tags:** build, test"));
2300 assert!(out.contains("**Role:** `worker`"));
2301 assert!(out.contains("### State Machine"));
2302 assert!(out.contains("**Initial:** `pending`"));
2303 assert!(out.contains("### Role Prompts"));
2304 assert!(out.contains("> You are actively working."));
2305 assert!(out.contains("### Paths"));
2306 }
2307
2308 #[test]
2311 fn test_format_update_output_json_mode() {
2312 let v = json!({ "task": "t1", "status": "working" });
2313 let out = format_update_output(v.clone(), CliOutputFormat::Json);
2314 assert_eq!(out, serde_json::to_string_pretty(&v).unwrap());
2315 }
2316
2317 #[test]
2318 fn test_format_update_output_markdown_with_prompts() {
2319 let v = json!({
2320 "task": "fix-bug",
2321 "title": "Fix auth bug",
2322 "status": "working",
2323 "prompts": [
2324 "You are now actively working on this task.",
2325 "Remember to run tests before completing."
2326 ]
2327 });
2328 let out = format_update_output(v, CliOutputFormat::Markdown);
2329 assert!(out.contains("**Task:** `fix-bug` - Fix auth bug"));
2330 assert!(out.contains("**Status:** `working`"));
2331 assert!(out.contains("### Guidance"));
2332 assert!(out.contains("> You are now actively working on this task."));
2333 assert!(out.contains("> Remember to run tests before completing."));
2334 }
2335
2336 #[test]
2337 fn test_format_update_output_markdown_no_prompts() {
2338 let v = json!({
2339 "task": "t1",
2340 "title": "Some task",
2341 "status": "pending"
2342 });
2343 let out = format_update_output(v, CliOutputFormat::Markdown);
2344 assert!(out.contains("**Task:** `t1`"));
2345 assert!(!out.contains("### Guidance"));
2346 }
2347
2348 #[test]
2351 fn test_format_claim_output_markdown_with_prompts() {
2352 let v = json!({
2353 "task": "task-1",
2354 "title": "Implement feature",
2355 "status": "working",
2356 "owner": "worker-5",
2357 "prompts": ["Start by reading the existing code."]
2358 });
2359 let out = format_claim_output(v, CliOutputFormat::Markdown);
2360 assert!(out.contains("**Task:** `task-1` - Implement feature"));
2361 assert!(out.contains("**Owner:** `worker-5`"));
2362 assert!(out.contains("### Guidance"));
2363 assert!(out.contains("> Start by reading the existing code."));
2364 }
2365}