Skip to main content

imp_core/tools/
mana.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use mana::commands::agents::{agents_file_path, load_agents};
6use mana::commands::logs::find_all_logs;
7use mana::commands::next::ScoredUnit;
8use mana::commands::run::{NativeRunParams, RunSummary, RunTarget, RunUnitStatus, RunView};
9use mana::stream::StreamEvent;
10use mana_core::ops::claim::ClaimParams;
11use mana_core::unit::{OnFailAction, UnitType};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14
15use super::{truncate_head, Tool, ToolContext, ToolOutput, ToolUpdate};
16use crate::error::Result;
17use crate::ui::{NotifyLevel, WidgetContent};
18const MAX_OUTPUT_LINES: usize = 2000;
19const MAX_OUTPUT_BYTES: usize = 50 * 1024;
20const MAX_STORED_RUN_EVENTS: usize = 64;
21const MAX_PERSISTED_RUN_LOG_LINES: usize = 50;
22const FINISHED_RUN_TTL_MS: u128 = 60 * 60 * 1000;
23
24fn find_mana_dir(cwd: &Path) -> std::result::Result<std::path::PathBuf, String> {
25    mana_core::discovery::find_mana_dir(cwd).map_err(|e| e.to_string())
26}
27
28fn resolve_mana_dir(
29    cwd: &Path,
30    params: &serde_json::Value,
31) -> std::result::Result<std::path::PathBuf, String> {
32    let scope = params
33        .get("scope")
34        .and_then(|v| v.as_str())
35        .or_else(|| params.get("mana_scope").and_then(|v| v.as_str()))
36        .unwrap_or("auto");
37
38    if let Some(explicit) = params
39        .get("path")
40        .and_then(|v| v.as_str())
41        .or_else(|| params.get("mana_dir").and_then(|v| v.as_str()))
42    {
43        let path = Path::new(explicit);
44        let resolved = if path.is_absolute() {
45            path.to_path_buf()
46        } else {
47            cwd.join(path)
48        };
49        return Ok(
50            if resolved.file_name().and_then(|name| name.to_str()) == Some(".mana") {
51                resolved
52            } else {
53                resolved.join(".mana")
54            },
55        );
56    }
57
58    match scope {
59        "auto" | "project" => find_mana_dir(cwd),
60        "root" => mana_core::discovery::find_outermost_mana_dir(cwd).map_err(|e| e.to_string()),
61        other => Err(format!(
62            "Unknown mana scope '{other}'. Use auto, project, or root."
63        )),
64    }
65}
66
67fn json_output(value: &impl serde::Serialize) -> ToolOutput {
68    match serde_json::to_string_pretty(value) {
69        Ok(json) => ToolOutput {
70            content: vec![imp_llm::ContentBlock::Text { text: json }],
71            details: serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
72            is_error: false,
73        },
74        Err(e) => ToolOutput::error(format!("Failed to serialize: {e}")),
75    }
76}
77
78fn send_update(ctx: &ToolContext, text: impl Into<String>, details: serde_json::Value) {
79    let _ = ctx.update_tx.try_send(ToolUpdate {
80        content: vec![imp_llm::ContentBlock::Text { text: text.into() }],
81        details,
82    });
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86struct NativeRunParamsView {
87    target: serde_json::Value,
88    jobs: u32,
89    dry_run: bool,
90    loop_mode: bool,
91    keep_going: bool,
92    timeout: u32,
93    idle_timeout: u32,
94    review: bool,
95}
96
97impl From<&NativeRunParams> for NativeRunParamsView {
98    fn from(args: &NativeRunParams) -> Self {
99        let target = match &args.target {
100            RunTarget::AllReady => json!({"kind": "all_ready"}),
101            RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
102            RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
103        };
104        Self {
105            target,
106            jobs: args.jobs,
107            dry_run: args.dry_run,
108            loop_mode: args.loop_mode,
109            keep_going: args.keep_going,
110            timeout: args.timeout,
111            idle_timeout: args.idle_timeout,
112            review: args.review,
113        }
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118struct NativeRunState {
119    run_id: String,
120    scope: String,
121    background: bool,
122    status: String,
123    error: Option<String>,
124    started_at_ms: u128,
125    finished_at_ms: Option<u128>,
126    args: NativeRunParamsView,
127    runtime: Option<serde_json::Value>,
128    summary: RunSummary,
129    units: Vec<RunUnitStatus>,
130    log_lines: Vec<String>,
131    event_count: usize,
132}
133
134impl NativeRunState {
135    fn new(run_id: String, scope: String, background: bool, args: &NativeRunParams) -> Self {
136        Self {
137            run_id,
138            scope,
139            background,
140            status: "starting".to_string(),
141            error: None,
142            started_at_ms: unix_time_ms(),
143            finished_at_ms: None,
144            args: NativeRunParamsView::from(args),
145            runtime: None,
146            summary: RunSummary {
147                total_units: 0,
148                total_rounds: 0,
149                total_closed: 0,
150                total_failed: 0,
151                total_abandoned: 0,
152                total_awaiting_verify: 0,
153                total_skipped: 0,
154                duration_secs: 0,
155            },
156            units: Vec::new(),
157            log_lines: Vec::new(),
158            event_count: 0,
159        }
160    }
161
162    fn apply_event(&mut self, event: &StreamEvent) {
163        self.event_count += 1;
164        if let Some(line) = stream_event_line(event) {
165            self.log_lines.push(line);
166            trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
167        }
168
169        match event {
170            StreamEvent::RunStart {
171                total_units,
172                total_rounds,
173                units,
174                runtime,
175                ..
176            } => {
177                self.status = "running".to_string();
178                self.summary.total_units = *total_units;
179                self.summary.total_rounds = *total_rounds;
180                self.runtime = runtime
181                    .as_ref()
182                    .and_then(|value| serde_json::to_value(value).ok());
183                self.units = units
184                    .iter()
185                    .map(|info| RunUnitStatus {
186                        id: info.id.clone(),
187                        title: info.title.clone(),
188                        status: "queued".to_string(),
189                        round: Some(info.round),
190                        agent: None,
191                        model: None,
192                        duration_secs: None,
193                        tool_count: None,
194                        turns: None,
195                        failure_summary: None,
196                        error: None,
197                    })
198                    .collect();
199                self.units.sort_by(|a, b| a.id.cmp(&b.id));
200            }
201            StreamEvent::RunPlan {
202                total_units,
203                runtime,
204                ..
205            } => {
206                self.status = "running".to_string();
207                self.summary.total_units = (*total_units).max(self.summary.total_units);
208                if runtime.is_some() {
209                    self.runtime = runtime
210                        .as_ref()
211                        .and_then(|value| serde_json::to_value(value).ok());
212                }
213            }
214            StreamEvent::RoundStart { total_rounds, .. } => {
215                self.status = "running".to_string();
216                self.summary.total_rounds = (*total_rounds).max(self.summary.total_rounds);
217            }
218            StreamEvent::UnitReady { id, title, .. } => {
219                let unit = ensure_unit_status(&mut self.units, id, title);
220                unit.status = "queued".to_string();
221            }
222            StreamEvent::UnitStart {
223                id, title, round, ..
224            } => {
225                self.status = "running".to_string();
226                let unit = ensure_unit_status(&mut self.units, id, title);
227                unit.title = title.clone();
228                unit.round = Some(*round);
229                unit.status = "running".to_string();
230            }
231            StreamEvent::UnitDone {
232                id,
233                success,
234                duration_secs,
235                error,
236                tool_count,
237                turns,
238                failure_summary,
239                ..
240            } => {
241                let unit = ensure_unit_status(&mut self.units, id, id);
242                unit.status = if *success { "done" } else { "failed" }.to_string();
243                unit.duration_secs = Some(*duration_secs);
244                unit.tool_count = *tool_count;
245                unit.turns = *turns;
246                unit.failure_summary = failure_summary.clone();
247                unit.error = error.clone();
248            }
249            StreamEvent::BatchVerify { passed, failed, .. } => {
250                for id in passed {
251                    let unit = ensure_unit_status(&mut self.units, id, id);
252                    unit.status = "done".to_string();
253                }
254                for id in failed {
255                    let unit = ensure_unit_status(&mut self.units, id, id);
256                    unit.status = "failed".to_string();
257                }
258            }
259            StreamEvent::RunEnd {
260                total_closed,
261                total_failed,
262                total_abandoned,
263                total_awaiting_verify,
264                total_skipped,
265                duration_secs,
266                ..
267            } => {
268                self.summary.total_closed = *total_closed;
269                self.summary.total_failed = *total_failed;
270                self.summary.total_abandoned = *total_abandoned;
271                self.summary.total_awaiting_verify = *total_awaiting_verify;
272                self.summary.total_skipped = *total_skipped;
273                self.summary.duration_secs = *duration_secs;
274                self.status = "finished".to_string();
275                self.finished_at_ms = Some(unix_time_ms());
276            }
277            StreamEvent::DryRun { runtime, .. } => {
278                self.status = "finished".to_string();
279                if runtime.is_some() {
280                    self.runtime = runtime
281                        .as_ref()
282                        .and_then(|value| serde_json::to_value(value).ok());
283                }
284                self.finished_at_ms = Some(unix_time_ms());
285            }
286            StreamEvent::Error { message } => {
287                self.status = "failed".to_string();
288                self.error = Some(message.clone());
289                self.finished_at_ms = Some(unix_time_ms());
290            }
291            _ => {}
292        }
293    }
294
295    fn finish_with_view(&mut self, view: &RunView) {
296        self.summary = view.summary.clone();
297        self.units = view.units.clone();
298        self.runtime = view
299            .runtime
300            .as_ref()
301            .and_then(|value| serde_json::to_value(value).ok());
302        self.status = "finished".to_string();
303        self.error = None;
304        self.finished_at_ms = Some(unix_time_ms());
305    }
306
307    fn fail(&mut self, error: String) {
308        self.status = "failed".to_string();
309        self.error = Some(error.clone());
310        self.finished_at_ms = Some(unix_time_ms());
311        self.log_lines.push(error);
312        trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
313    }
314
315    fn persisted(&self) -> Self {
316        let mut state = self.clone();
317        trim_log_lines(&mut state.log_lines, MAX_PERSISTED_RUN_LOG_LINES);
318        state
319    }
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323struct ManaRunStore {
324    next_id: u64,
325    runs: Vec<NativeRunState>,
326}
327
328impl ManaRunStore {
329    fn start_run(&mut self, scope: String, background: bool, args: &NativeRunParams) -> String {
330        self.next_id += 1;
331        let run_id = format!("run-{}", self.next_id);
332        self.runs
333            .push(NativeRunState::new(run_id.clone(), scope, background, args));
334        self.trim_history();
335        run_id
336    }
337
338    fn persist(&self) {
339        let path = run_state_file();
340        let persisted = Self {
341            next_id: self.next_id,
342            runs: self.runs.iter().map(NativeRunState::persisted).collect(),
343        };
344        if let Ok(json) = serde_json::to_string_pretty(&persisted) {
345            let _ = std::fs::write(path, json);
346        }
347    }
348
349    fn load_persisted() -> Self {
350        let path = run_state_file();
351        if !path.exists() {
352            return Self::default();
353        }
354
355        let Ok(contents) = std::fs::read_to_string(path) else {
356            return Self::default();
357        };
358        if contents.trim().is_empty() {
359            return Self::default();
360        }
361
362        let Ok(mut store) = serde_json::from_str::<Self>(&contents) else {
363            return Self::default();
364        };
365
366        store.discard_expired_finished_runs();
367        store.trim_history();
368        store
369    }
370
371    fn discard_expired_finished_runs(&mut self) {
372        let cutoff = unix_time_ms().saturating_sub(FINISHED_RUN_TTL_MS);
373        self.runs.retain(|run| match run.finished_at_ms {
374            Some(finished_at_ms) => finished_at_ms >= cutoff,
375            None => true,
376        });
377    }
378
379    fn update_with_event(&mut self, run_id: &str, event: &StreamEvent) {
380        if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
381            run.apply_event(event);
382        }
383    }
384
385    fn finish_run(&mut self, run_id: &str, view: &RunView) {
386        if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
387            run.finish_with_view(view);
388        }
389        self.trim_history();
390    }
391
392    fn fail_run(&mut self, run_id: &str, error: String) {
393        if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
394            run.fail(error);
395        }
396        self.trim_history();
397    }
398
399    fn snapshot(&self, run_id: Option<&str>) -> Option<NativeRunState> {
400        if let Some(run_id) = run_id {
401            return self.runs.iter().find(|run| run.run_id == run_id).cloned();
402        }
403
404        self.runs
405            .iter()
406            .rev()
407            .find(|run| run.status == "starting" || run.status == "running")
408            .cloned()
409            .or_else(|| self.runs.last().cloned())
410    }
411
412    fn trim_history(&mut self) {
413        while self.runs.len() > 8 {
414            let newest_index = self.runs.len().saturating_sub(1);
415            if let Some(index) =
416                self.runs
417                    .iter()
418                    .enumerate()
419                    .take(newest_index)
420                    .find_map(|(index, run)| {
421                        (run.status != "starting" && run.status != "running").then_some(index)
422                    })
423            {
424                self.runs.remove(index);
425            } else if !self.runs.is_empty() {
426                self.runs.remove(0);
427            } else {
428                break;
429            }
430        }
431    }
432}
433
434fn trim_log_lines(log_lines: &mut Vec<String>, max_lines: usize) {
435    if log_lines.len() > max_lines {
436        let overflow = log_lines.len() - max_lines;
437        log_lines.drain(0..overflow);
438    }
439}
440
441fn run_state_file() -> std::path::PathBuf {
442    if let Ok(path) = agents_file_path() {
443        if let Some(dir) = path.parent() {
444            std::fs::create_dir_all(dir).ok();
445            return dir.join("run_state.json");
446        }
447    }
448
449    let dir = std::env::var("HOME")
450        .map(|h| {
451            std::path::PathBuf::from(h)
452                .join(".local")
453                .join("share")
454                .join("units")
455        })
456        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp").join("mana"));
457    std::fs::create_dir_all(&dir).ok();
458    dir.join("run_state.json")
459}
460
461fn unix_time_ms() -> u128 {
462    std::time::SystemTime::now()
463        .duration_since(std::time::UNIX_EPOCH)
464        .unwrap_or_default()
465        .as_millis()
466}
467
468fn ensure_unit_status<'a>(
469    units: &'a mut Vec<RunUnitStatus>,
470    id: &str,
471    title: &str,
472) -> &'a mut RunUnitStatus {
473    if let Some(index) = units.iter().position(|unit| unit.id == id) {
474        return &mut units[index];
475    }
476
477    units.push(RunUnitStatus {
478        id: id.to_string(),
479        title: title.to_string(),
480        status: "queued".to_string(),
481        round: None,
482        agent: None,
483        model: None,
484        duration_secs: None,
485        tool_count: None,
486        turns: None,
487        failure_summary: None,
488        error: None,
489    });
490    let index = units.len() - 1;
491    &mut units[index]
492}
493
494fn stream_event_line(event: &StreamEvent) -> Option<String> {
495    match event {
496        StreamEvent::RunStart {
497            total_units,
498            total_rounds,
499            ..
500        } => Some(format!(
501            "Mana run started: {total_units} jobs across {total_rounds} waves"
502        )),
503        StreamEvent::RunPlan {
504            waves,
505            file_overlaps,
506            ..
507        } => Some(format!(
508            "Plan ready: {} waves · {} overlapping file groups",
509            waves.len(),
510            file_overlaps.len()
511        )),
512        StreamEvent::RoundStart {
513            round,
514            total_rounds,
515            unit_count,
516        } => Some(format!(
517            "Round {round}/{total_rounds}: {unit_count} unit(s)"
518        )),
519        StreamEvent::UnitReady {
520            id,
521            title,
522            unblocked_by,
523        } => Some(format!("Ready: {id} {title} (unblocked by {unblocked_by})")),
524        StreamEvent::UnitStart {
525            id, title, round, ..
526        } => Some(format!("▶ {id}  {title}  wave {round}")),
527        StreamEvent::UnitThinking { id, text } => {
528            Some(format!("… {id}  {}", truncate_line_for_log(text)))
529        }
530        StreamEvent::UnitTool {
531            id,
532            tool_name,
533            tool_count,
534            file_path,
535        } => Some(match file_path {
536            Some(path) => format!("⚙ {id}  #{tool_count} {tool_name}  {path}"),
537            None => format!("⚙ {id}  #{tool_count} {tool_name}"),
538        }),
539        StreamEvent::UnitTokens {
540            id,
541            input_tokens,
542            output_tokens,
543            cost,
544            ..
545        } => Some(format!(
546            "$ {id}  in {input_tokens} · out {output_tokens} · ${cost:.4}"
547        )),
548        StreamEvent::UnitDone {
549            id,
550            success,
551            duration_secs,
552            error,
553            ..
554        } => Some(if *success {
555            format!("✓ {id}  done  {duration_secs}s")
556        } else {
557            format!(
558                "✗ {id}  failed  {}",
559                error.clone().unwrap_or_else(|| "error".to_string())
560            )
561        }),
562        StreamEvent::RoundEnd {
563            round,
564            success_count,
565            failed_count,
566        } => Some(format!(
567            "Round {round} complete: {success_count} done · {failed_count} failed"
568        )),
569        StreamEvent::RunEnd {
570            total_closed,
571            total_failed,
572            duration_secs,
573            ..
574        } => Some(format!(
575            "Mana run finished: {total_closed} done · {total_failed} failed · {duration_secs}s"
576        )),
577        StreamEvent::BatchVerify {
578            commands_run,
579            passed,
580            failed,
581        } => Some(format!(
582            "Batch verify: {commands_run} command(s) · {} passed · {} failed",
583            passed.len(),
584            failed.len()
585        )),
586        StreamEvent::VerifyGroupRun {
587            command,
588            unit_ids,
589            success,
590        } => Some(format!(
591            "Verify command: {} · {} unit(s) · {}",
592            truncate_line_for_log(command),
593            unit_ids.len(),
594            if *success { "passed" } else { "failed" }
595        )),
596        StreamEvent::DryRun { rounds, .. } => {
597            Some(format!("Dry run: {} planned wave(s)", rounds.len()))
598        }
599        StreamEvent::Error { message } => Some(format!("Run error: {message}")),
600    }
601}
602
603fn truncate_line_for_log(text: &str) -> String {
604    const MAX_CHARS: usize = 160;
605    let mut out = String::new();
606    let mut chars = text.chars();
607    for _ in 0..MAX_CHARS {
608        if let Some(ch) = chars.next() {
609            out.push(ch);
610        } else {
611            return out;
612        }
613    }
614    if chars.next().is_some() {
615        out.push('…');
616    }
617    out
618}
619
620fn update_run_store_with_event(
621    store: &std::sync::Mutex<ManaRunStore>,
622    run_id: &str,
623    event: &StreamEvent,
624) {
625    if let Ok(mut store) = store.lock() {
626        store.update_with_event(run_id, event);
627    }
628}
629
630fn finish_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, view: &RunView) {
631    if let Ok(mut store) = store.lock() {
632        store.finish_run(run_id, view);
633        store.persist();
634    }
635}
636
637fn fail_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, error: String) {
638    if let Ok(mut store) = store.lock() {
639        store.fail_run(run_id, error);
640        store.persist();
641    }
642}
643
644fn run_summary_lines(view: &RunView) -> Vec<String> {
645    let mut lines = vec![format!(
646        "Mana run: {} total · {} done · {} failed · {} candidate complete / awaiting verify · {} skipped",
647        view.summary.total_units,
648        view.summary.total_closed,
649        view.summary.total_failed,
650        view.summary.total_awaiting_verify,
651        view.summary.total_skipped
652    )];
653
654    for unit in &view.units {
655        let marker = match unit.status.as_str() {
656            "running" => "▶",
657            "done" => "✓",
658            "failed" => "✗",
659            "blocked" => "!",
660            _ => "…",
661        };
662        let mut extras = Vec::new();
663        if let Some(round) = unit.round {
664            extras.push(format!("wave {round}"));
665        }
666        if let Some(agent) = &unit.agent {
667            extras.push(agent.clone());
668        }
669        if let Some(duration) = unit.duration_secs {
670            extras.push(format!("{}s", duration));
671        }
672        let extra_suffix = if extras.is_empty() {
673            String::new()
674        } else {
675            format!("  {}", extras.join(" · "))
676        };
677        lines.push(format!(
678            "{marker} {}  {}  {}{}",
679            unit.id, unit.title, unit.status, extra_suffix
680        ));
681    }
682
683    lines
684}
685
686fn mana_widget_lines(summary: impl Into<String>, detail: Option<String>) -> WidgetContent {
687    let mut lines = vec![summary.into()];
688    if let Some(detail) = detail {
689        lines.push(detail);
690    }
691    WidgetContent::Lines(lines)
692}
693
694async fn set_mana_delta_widget(
695    ctx: &ToolContext,
696    summary: impl Into<String>,
697    detail: Option<String>,
698) {
699    ctx.ui
700        .set_widget("mana", Some(mana_widget_lines(summary, detail)))
701        .await;
702}
703
704fn unit_delta_label(unit: &serde_json::Value) -> Option<String> {
705    let id = unit.get("id").and_then(|v| v.as_str())?;
706    let title = unit
707        .get("title")
708        .and_then(|v| v.as_str())
709        .unwrap_or("(untitled)");
710    Some(format!("{id} · {title}"))
711}
712
713fn target_from_params(params: &serde_json::Value) -> Result<RunTarget> {
714    if let Some(values) = params["targets"].as_array() {
715        let ids: Vec<String> = values
716            .iter()
717            .filter_map(|value| value.as_str().map(|s| s.to_string()))
718            .collect();
719        if ids.is_empty() {
720            return Err(crate::error::Error::Tool(
721                "mana run targets must contain at least one string id".into(),
722            ));
723        }
724        return Ok(RunTarget::Explicit(ids));
725    }
726
727    if let Some(id) = params["id"].as_str() {
728        return Ok(RunTarget::Unit(id.to_string()));
729    }
730
731    Ok(RunTarget::AllReady)
732}
733
734fn scope_from_target(target: &RunTarget) -> String {
735    match target {
736        RunTarget::AllReady => "all ready units".to_string(),
737        RunTarget::Unit(id) => format!("unit {id}"),
738        RunTarget::Explicit(ids) => format!("targets {}", ids.join(", ")),
739    }
740}
741
742fn make_follow_up_summary(scope: &str, view: &RunView) -> String {
743    let mut summary = if view.summary.total_failed > 0 {
744        format!(
745            "Native mana orchestration finished for {scope}: {} done, {} failed, {} candidate complete / awaiting verify.",
746            view.summary.total_closed,
747            view.summary.total_failed,
748            view.summary.total_awaiting_verify
749        )
750    } else if view.summary.total_awaiting_verify > 0 {
751        format!(
752            "Native mana orchestration finished for {scope}: {} done, {} candidate complete / awaiting verify.",
753            view.summary.total_closed, view.summary.total_awaiting_verify
754        )
755    } else {
756        format!(
757            "Native mana orchestration finished for {scope}: {} done, 0 failed.",
758            view.summary.total_closed
759        )
760    };
761
762    if let Some(runtime) = &view.runtime {
763        let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
764        let model = runtime.model.as_deref().unwrap_or("default-model");
765        summary.push_str(&format!(
766            " Orchestration ran through mana; worker runtime: {agent} · {model}."
767        ));
768    }
769
770    summary.push_str(" Inspect with mana(action=\"run_state\") or mana(action=\"evaluate\").");
771    summary
772}
773
774fn parse_csv_strings(value: &serde_json::Value, field_name: &str) -> Result<Vec<String>> {
775    if let Some(values) = value.as_array() {
776        let parsed = values
777            .iter()
778            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
779            .filter(|s| !s.is_empty())
780            .collect();
781        return Ok(parsed);
782    }
783
784    if let Some(raw) = value.as_str() {
785        return Ok(raw
786            .split(',')
787            .map(|s| s.trim().to_string())
788            .filter(|s| !s.is_empty())
789            .collect());
790    }
791
792    if value.is_null() {
793        return Ok(Vec::new());
794    }
795
796    Err(crate::error::Error::Tool(format!(
797        "{field_name} must be a comma-separated string or array of strings"
798    )))
799}
800
801fn parse_optional_string(value: &serde_json::Value) -> Option<String> {
802    value
803        .as_str()
804        .map(str::trim)
805        .filter(|s| !s.is_empty())
806        .map(|s| s.to_string())
807}
808
809fn parse_on_fail(value: &serde_json::Value) -> Result<Option<OnFailAction>> {
810    if value.is_null() {
811        return Ok(None);
812    }
813
814    if let Some(raw) = value.as_str() {
815        return mana_core::ops::create::parse_on_fail(raw)
816            .map(Some)
817            .map_err(|e| crate::error::Error::Tool(e.to_string()));
818    }
819
820    let Some(obj) = value.as_object() else {
821        return Err(crate::error::Error::Tool(
822            "on_fail must be a string like 'retry:3'/'escalate:P1' or an object".into(),
823        ));
824    };
825
826    let action = obj
827        .get("action")
828        .and_then(|v| v.as_str())
829        .ok_or_else(|| crate::error::Error::Tool("on_fail object requires 'action'".into()))?;
830
831    match action {
832        "retry" => Ok(Some(OnFailAction::Retry {
833            max: obj.get("max").and_then(|v| v.as_u64()).map(|v| v as u32),
834            delay_secs: obj.get("delay_secs").and_then(|v| v.as_u64()),
835        })),
836        "escalate" => Ok(Some(OnFailAction::Escalate {
837            priority: obj
838                .get("priority")
839                .and_then(|v| v.as_u64())
840                .map(|v| v as u8),
841            message: obj
842                .get("message")
843                .and_then(|v| v.as_str())
844                .map(|s| s.trim().to_string())
845                .filter(|s| !s.is_empty()),
846        })),
847        other => Err(crate::error::Error::Tool(format!(
848            "unsupported on_fail action: {other}"
849        ))),
850    }
851}
852
853fn parse_unit_kind(value: &serde_json::Value) -> Result<Option<UnitType>> {
854    let Some(raw) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) else {
855        return Ok(None);
856    };
857
858    match raw {
859        "epic" => Ok(Some(UnitType::Epic)),
860        "task" | "job" => Ok(Some(UnitType::Task)),
861        "fact" => Ok(Some(UnitType::Fact)),
862        other => Err(crate::error::Error::Tool(format!(
863            "type must be one of: epic, task, fact (legacy alias: job; got {other})"
864        ))),
865    }
866}
867
868fn background_run_started_output(
869    scope: &str,
870    run_id: &str,
871    run_args: &NativeRunParams,
872) -> ToolOutput {
873    let text = format!(
874        "Started native mana orchestration in background for {scope} as {run_id}. Mana will coordinate the run and dispatch imp workers underneath. Use mana(action=\"run_state\", run_id=\"{run_id}\") for orchestration status, mana(action=\"logs\", run_id=\"{run_id}\") for recent native events, and mana(action=\"agents\") / mana(action=\"logs\", id=...) for worker output."
875    );
876    ToolOutput {
877        content: vec![imp_llm::ContentBlock::Text { text }],
878        details: json!({
879            "background": true,
880            "run_id": run_id,
881            "scope": scope,
882            "target": match &run_args.target {
883                RunTarget::AllReady => json!({"kind": "all_ready"}),
884                RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
885                RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
886            },
887            "jobs": run_args.jobs,
888            "loop": run_args.loop_mode,
889            "dry_run": run_args.dry_run,
890            "review": run_args.review,
891        }),
892        is_error: false,
893    }
894}
895
896fn spawn_background_run(
897    mana_dir: std::path::PathBuf,
898    run_args: NativeRunParams,
899    ctx: ToolContext,
900    run_store: Arc<std::sync::Mutex<ManaRunStore>>,
901    run_id: String,
902) {
903    let ui = ctx.ui.clone();
904    let command_tx = ctx.command_tx.clone();
905    let scope = scope_from_target(&run_args.target);
906
907    tokio::spawn(async move {
908        ui.set_status(
909            "mana",
910            Some(&format!("mana orchestration: running {scope}")),
911        )
912        .await;
913        ui.set_widget(
914            "mana",
915            Some(mana_widget_lines(
916                format!("orchestrating {scope}"),
917                Some(format!(
918                    "native mana tool → mana orchestration → imp workers · inspect with mana run_state/logs (run_id={run_id})"
919                )),
920            )),
921        )
922        .await;
923
924        let run_store_for_sink = run_store.clone();
925        let run_id_for_sink = run_id.clone();
926        let result = tokio::task::spawn_blocking(move || {
927            mana::commands::run::run_with_stream_capture_and_sink(
928                &mana_dir,
929                run_args,
930                Some(Arc::new(move |event| {
931                    update_run_store_with_event(&run_store_for_sink, &run_id_for_sink, &event);
932                })),
933            )
934        })
935        .await;
936
937        match result {
938            Ok(Ok(view)) => {
939                finish_run_in_store(&run_store, &run_id, &view);
940                let summary = format!(
941                    "mana orchestration: {scope} finished · {} done · {} failed",
942                    view.summary.total_closed, view.summary.total_failed
943                );
944                let runtime_detail = view
945                    .runtime
946                    .as_ref()
947                    .map(|runtime| {
948                        let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
949                        let model = runtime.model.as_deref().unwrap_or("default-model");
950                        format!(
951                            "native mana tool → mana orchestration → {agent} workers · {scope} · {model}"
952                        )
953                    })
954                    .unwrap_or_else(|| scope.clone());
955                ui.set_status("mana", Some(&summary)).await;
956                ui.set_widget(
957                    "mana",
958                    Some(mana_widget_lines(summary.clone(), Some(runtime_detail))),
959                )
960                .await;
961                ui.notify(&summary, NotifyLevel::Info).await;
962                if !ui.has_ui() {
963                    let _ = command_tx
964                        .send(crate::agent::AgentCommand::FollowUp(
965                            make_follow_up_summary(&scope, &view),
966                        ))
967                        .await;
968                }
969                let ui_clear = ui.clone();
970                tokio::spawn(async move {
971                    tokio::time::sleep(std::time::Duration::from_secs(12)).await;
972                    ui_clear.set_widget("mana", None).await;
973                    ui_clear.set_status("mana", None).await;
974                });
975            }
976            Ok(Err(err)) => {
977                let message = format!("mana orchestration: {scope} failed: {err}");
978                fail_run_in_store(&run_store, &run_id, message.clone());
979                ui.set_status("mana", Some(&message)).await;
980                ui.set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
981                    .await;
982                ui.notify(&message, NotifyLevel::Error).await;
983                if !ui.has_ui() {
984                    let _ = command_tx
985                        .send(crate::agent::AgentCommand::FollowUp(format!(
986                            "Native mana orchestration failed for {scope}: {err}. Inspect with mana(action=\"run_state\") or mana(action=\"logs\", run_id=\"{run_id}\")."
987                        )))
988                        .await;
989                }
990            }
991            Err(join_err) => {
992                let message = format!("mana orchestration: {scope} task failed: {join_err}");
993                fail_run_in_store(&run_store, &run_id, message.clone());
994                ui.set_status("mana", Some(&message)).await;
995                ui.set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
996                    .await;
997                ui.notify(&message, NotifyLevel::Error).await;
998                if !ui.has_ui() {
999                    let _ = command_tx
1000                        .send(crate::agent::AgentCommand::FollowUp(format!(
1001                            "Native mana orchestration background task failed for {scope}: {join_err}. Inspect with mana(action=\"run_state\") or mana(action=\"logs\", run_id=\"{run_id}\")."
1002                        )))
1003                        .await;
1004                }
1005            }
1006        }
1007    });
1008}
1009
1010fn text_output(text: String, details: serde_json::Value) -> ToolOutput {
1011    ToolOutput {
1012        content: vec![imp_llm::ContentBlock::Text { text }],
1013        details,
1014        is_error: false,
1015    }
1016}
1017
1018fn run_state_snapshot(
1019    run_store: &Arc<std::sync::Mutex<ManaRunStore>>,
1020    run_id: Option<&str>,
1021) -> Option<NativeRunState> {
1022    run_store
1023        .lock()
1024        .ok()
1025        .and_then(|store| store.snapshot(run_id))
1026}
1027
1028fn run_state_output(state: &NativeRunState) -> ToolOutput {
1029    let mut lines = vec![format!(
1030        "Native mana orchestration {}: {} · {}",
1031        state.run_id, state.scope, state.status
1032    )];
1033    if let Some(runtime) = &state.runtime {
1034        let agent = runtime["direct_agent"].as_str().unwrap_or("imp-worker");
1035        let model = runtime["model"].as_str().unwrap_or("default-model");
1036        lines.push(format!("Worker runtime: {agent} · {model}"));
1037    }
1038    lines.push(format!(
1039        "{} total · {} done · {} failed · {} candidate complete / awaiting verify · {} skipped",
1040        state.summary.total_units,
1041        state.summary.total_closed,
1042        state.summary.total_failed,
1043        state.summary.total_awaiting_verify,
1044        state.summary.total_skipped
1045    ));
1046
1047    if !state.units.is_empty() {
1048        let preview = state
1049            .units
1050            .iter()
1051            .take(3)
1052            .map(|unit| format!("{}:{}", unit.id, unit.status))
1053            .collect::<Vec<_>>()
1054            .join(", ");
1055        lines.push(format!("Units: {preview}"));
1056    }
1057
1058    if let Some(last) = state.log_lines.last() {
1059        lines.push(format!("Latest: {last}"));
1060    }
1061    text_output(
1062        lines.join("\n"),
1063        serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
1064    )
1065}
1066
1067fn evaluate_run_output(state: &NativeRunState) -> ToolOutput {
1068    let headline = match state.status.as_str() {
1069        "starting" | "running" => {
1070            format!("Native mana orchestration run {} is still running for {}.", state.run_id, state.scope)
1071        }
1072        "failed" => format!("Native mana orchestration run {} failed for {}.", state.run_id, state.scope),
1073        _ if state.summary.total_failed > 0 => format!(
1074            "Native mana orchestration run {} finished with {} failed unit(s).",
1075            state.run_id, state.summary.total_failed
1076        ),
1077        _ if state.summary.total_awaiting_verify > 0 => format!(
1078            "Native mana orchestration run {} finished with {} unit(s) candidate complete / awaiting verify.",
1079            state.run_id, state.summary.total_awaiting_verify
1080        ),
1081        _ => format!(
1082            "Native mana orchestration run {} finished successfully: {} unit(s) done.",
1083            state.run_id, state.summary.total_closed
1084        ),
1085    };
1086
1087    let runtime = state
1088        .runtime
1089        .as_ref()
1090        .map(|runtime| {
1091            format!(
1092                "Worker runtime: {} · {}",
1093                runtime["direct_agent"].as_str().unwrap_or("imp-worker"),
1094                runtime["model"].as_str().unwrap_or("default-model")
1095            )
1096        })
1097        .unwrap_or_else(|| "Runtime: unknown".to_string());
1098
1099    let latest = state
1100        .log_lines
1101        .last()
1102        .map(|line| format!("Latest: {line}"))
1103        .unwrap_or_else(|| "Latest: (no stream events captured yet)".to_string());
1104
1105    text_output(
1106        format!("{headline}\n{runtime}\n{latest}"),
1107        serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
1108    )
1109}
1110
1111fn claim_output(result: &mana_core::ops::claim::ClaimResult) -> ToolOutput {
1112    let text = format!(
1113        "Claimed unit {} ({}) by {}",
1114        result.unit.id, result.unit.title, result.claimer
1115    );
1116    ToolOutput {
1117        content: vec![imp_llm::ContentBlock::Text { text }],
1118        details: json!({
1119            "unit": {
1120                "id": result.unit.id,
1121                "title": result.unit.title,
1122                "status": result.unit.status,
1123                "claimed_by": result.unit.claimed_by,
1124            },
1125            "claimer": result.claimer,
1126            "is_goal": result.is_goal,
1127            "path": result.path,
1128        }),
1129        is_error: false,
1130    }
1131}
1132
1133fn release_output(result: &mana_core::ops::claim::ReleaseResult) -> ToolOutput {
1134    let text = format!(
1135        "Released unit {} ({}) back to {}",
1136        result.unit.id, result.unit.title, result.unit.status
1137    );
1138    ToolOutput {
1139        content: vec![imp_llm::ContentBlock::Text { text }],
1140        details: json!({
1141            "unit": {
1142                "id": result.unit.id,
1143                "title": result.unit.title,
1144                "status": result.unit.status,
1145                "claimed_by": result.unit.claimed_by,
1146            },
1147            "path": result.path,
1148        }),
1149        is_error: false,
1150    }
1151}
1152
1153fn truncate_with_note(text: &str) -> String {
1154    let result = truncate_head(text, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
1155    if !result.truncated {
1156        return result.content;
1157    }
1158
1159    let mut output = result.content;
1160    output.push_str(&format!(
1161        "\n[Output truncated: showing first {} of {} lines{}]",
1162        result.output_lines,
1163        result.total_lines,
1164        result
1165            .temp_file
1166            .as_ref()
1167            .map(|p| format!(". Full output saved to {}", p.display()))
1168            .unwrap_or_default()
1169    ));
1170    output
1171}
1172
1173fn scored_units_to_text(units: &[ScoredUnit]) -> String {
1174    if units.is_empty() {
1175        return "No ready units. Create one with: mana create \"task\" --verify \"cmd\""
1176            .to_string();
1177    }
1178
1179    let mut lines = Vec::new();
1180    for unit in units {
1181        lines.push(format!(
1182            "P{}  {:.1}  {}",
1183            unit.priority, unit.score, unit.title
1184        ));
1185        if !unit.unblocks.is_empty() {
1186            lines.push(format!("      Unblocks: {}", unit.unblocks.join(", ")));
1187        }
1188        let attempts = if unit.attempts > 0 {
1189            format!(" | Attempts: {}", unit.attempts)
1190        } else {
1191            String::new()
1192        };
1193        lines.push(format!(
1194            "      ID: {} | Age: {} days{}",
1195            unit.id, unit.age_days, attempts
1196        ));
1197        lines.push(String::new());
1198    }
1199    lines.join("\n")
1200}
1201
1202fn tree_lines(node: &mana_core::api::TreeNode, indent: usize, out: &mut Vec<String>) {
1203    let prefix = "  ".repeat(indent);
1204    let verify = if node.has_verify { "spec" } else { "goal" };
1205    out.push(format!(
1206        "{}{} {} [{} P{} · {}]",
1207        prefix, node.id, node.title, node.status, node.priority, verify
1208    ));
1209    for child in &node.children {
1210        tree_lines(child, indent + 1, out);
1211    }
1212}
1213
1214pub struct ManaTool {
1215    run_store: Arc<std::sync::Mutex<ManaRunStore>>,
1216}
1217
1218impl Default for ManaTool {
1219    fn default() -> Self {
1220        Self {
1221            run_store: Arc::new(std::sync::Mutex::new(ManaRunStore::load_persisted())),
1222        }
1223    }
1224}
1225
1226#[async_trait]
1227impl Tool for ManaTool {
1228    fn name(&self) -> &str {
1229        "mana"
1230    }
1231    fn label(&self) -> &str {
1232        "Mana"
1233    }
1234    fn description(&self) -> &str {
1235        "Native mana work coordination: inspect, update, create, and run units or orchestration state. Prefer it over bash for equivalent mana actions."
1236    }
1237    fn parameters(&self) -> serde_json::Value {
1238        let string_or_array = || {
1239            json!({
1240                "oneOf": [
1241                    { "type": "string" },
1242                    { "type": "array", "items": { "type": "string" } }
1243                ]
1244            })
1245        };
1246
1247        let mut properties = serde_json::Map::new();
1248        properties.insert(
1249            "action".into(),
1250            json!({ "type": "string", "enum": ["status", "list", "show", "create", "close", "update", "run", "run_state", "evaluate", "claim", "release", "logs", "agents", "next", "tree", "reopen", "verify", "fail", "delete", "dep_add", "dep_remove", "fact_create", "fact_verify", "notes_append", "decision_add", "decision_resolve"] }),
1251        );
1252        properties.insert("id".into(), json!({ "type": "string" }));
1253        properties.insert(
1254            "scope".into(),
1255            json!({ "type": "string", "enum": ["auto", "project", "root"], "description": "Mana scope selection for this action" }),
1256        );
1257        properties.insert(
1258            "mana_scope".into(),
1259            json!({ "type": "string", "enum": ["auto", "project", "root"], "description": "Alias for scope" }),
1260        );
1261        properties.insert(
1262            "path".into(),
1263            json!({ "type": "string", "description": "Explicit project directory or .mana directory to target for this action" }),
1264        );
1265        properties.insert(
1266            "mana_dir".into(),
1267            json!({ "type": "string", "description": "Alias for path; explicit .mana or project directory to target" }),
1268        );
1269        properties.insert(
1270            "from_id".into(),
1271            json!({ "type": "string", "description": "Source unit ID for dependency updates" }),
1272        );
1273        properties.insert(
1274            "dep_id".into(),
1275            json!({ "type": "string", "description": "Dependency unit ID to add or remove" }),
1276        );
1277        properties.insert(
1278            "run_id".into(),
1279            json!({ "type": "string", "description": "Native in-session mana run ID, returned by action=run" }),
1280        );
1281        properties.insert("title".into(), json!({ "type": "string" }));
1282        properties.insert(
1283            "verify".into(),
1284            json!({ "type": "string", "description": "Shell command, must exit 0" }),
1285        );
1286        properties.insert("description".into(), json!({ "type": "string" }));
1287        properties.insert(
1288            "acceptance".into(),
1289            json!({ "type": "string", "description": "Concrete acceptance criteria for the unit" }),
1290        );
1291        properties.insert(
1292            "notes".into(),
1293            json!({ "type": "string", "description": "Progress log or authoring notes" }),
1294        );
1295        properties.insert(
1296            "design".into(),
1297            json!({ "type": "string", "description": "Supplemental design context for the unit" }),
1298        );
1299        properties.insert(
1300            "assignee".into(),
1301            json!({ "type": "string", "description": "Assignee or owner for the unit" }),
1302        );
1303        properties.insert("parent".into(), json!({ "type": "string" }));
1304        let mut deps = string_or_array();
1305        deps["description"] = json!("Dependency unit IDs as a comma-separated string or array");
1306        properties.insert("deps".into(), deps);
1307        let mut produces = string_or_array();
1308        produces["description"] = json!("Artifacts this unit produces");
1309        properties.insert("produces".into(), produces);
1310        let mut requires = string_or_array();
1311        requires["description"] = json!("Artifacts this unit requires");
1312        properties.insert("requires".into(), requires);
1313        let mut paths = string_or_array();
1314        paths["description"] = json!("Relevant file paths for context/relevance");
1315        properties.insert("paths".into(), paths);
1316        let mut decisions = string_or_array();
1317        decisions["description"] = json!("Blocking decisions to record on the unit");
1318        properties.insert("decisions".into(), decisions);
1319        let mut resolve_decisions = string_or_array();
1320        resolve_decisions["description"] =
1321            json!("Decision entries or indexes to resolve during update");
1322        properties.insert("resolve_decisions".into(), resolve_decisions);
1323        properties.insert("status".into(), json!({ "type": "string" }));
1324        properties.insert("priority".into(), json!({ "type": "integer" }));
1325        let mut labels = string_or_array();
1326        labels["description"] = json!("Labels as a comma-separated string or array");
1327        properties.insert("labels".into(), labels);
1328        properties.insert(
1329            "add_label".into(),
1330            json!({ "type": "string", "description": "Single label to add during update" }),
1331        );
1332        properties.insert(
1333            "remove_label".into(),
1334            json!({ "type": "string", "description": "Single label to remove during update" }),
1335        );
1336        properties.insert(
1337            "kind".into(),
1338            json!({ "type": "string", "enum": ["epic", "task", "fact", "job"], "description": "Explicit mana unit type (`job` is a legacy alias for `task`)" }),
1339        );
1340        properties.insert(
1341            "feature".into(),
1342            json!({ "type": "boolean", "description": "Whether the unit is a feature-level goal" }),
1343        );
1344        properties.insert(
1345            "fail_first".into(),
1346            json!({ "type": "boolean", "description": "Require verify to fail first at creation time" }),
1347        );
1348        properties.insert(
1349            "verify_timeout".into(),
1350            json!({ "type": "integer", "description": "Timeout in seconds for verify" }),
1351        );
1352        properties.insert(
1353            "on_fail".into(),
1354            json!({ "description": "On-fail policy as a string like retry:3 / escalate:P1 or an object" }),
1355        );
1356        properties.insert(
1357            "fact_title".into(),
1358            json!({ "type": "string", "description": "Title for fact_create; falls back to title" }),
1359        );
1360        properties.insert(
1361            "paths_csv".into(),
1362            json!({ "type": "string", "description": "Comma-separated paths for fact_create convenience" }),
1363        );
1364        properties.insert(
1365            "ttl_days".into(),
1366            json!({ "type": "integer", "description": "TTL in days for fact_create" }),
1367        );
1368        properties.insert(
1369            "pass_ok".into(),
1370            json!({ "type": "boolean", "description": "Permit fact creation even if verify currently passes" }),
1371        );
1372        properties.insert("force".into(), json!({ "type": "boolean" }));
1373        properties.insert("reason".into(), json!({ "type": "string" }));
1374        properties.insert("all".into(), json!({ "type": "boolean" }));
1375        properties.insert(
1376            "by".into(),
1377            json!({ "type": "string", "description": "Who is claiming the unit" }),
1378        );
1379        properties.insert(
1380            "count".into(),
1381            json!({ "type": "integer", "description": "Number of next recommendations to return" }),
1382        );
1383        properties.insert(
1384            "background".into(),
1385            json!({ "type": "boolean", "description": "Run mana orchestration in the background and return immediately (default true unless dry_run=true)" }),
1386        );
1387        properties.insert(
1388            "targets".into(),
1389            json!({ "type": "array", "items": { "type": "string" }, "description": "Explicit target unit IDs to run as a canonical target set" }),
1390        );
1391        properties.insert("jobs".into(), json!({ "type": "integer" }));
1392        properties.insert("dry_run".into(), json!({ "type": "boolean" }));
1393        properties.insert("loop".into(), json!({ "type": "boolean" }));
1394        properties.insert("keep_going".into(), json!({ "type": "boolean" }));
1395        properties.insert("timeout".into(), json!({ "type": "integer" }));
1396        properties.insert("idle_timeout".into(), json!({ "type": "integer" }));
1397        properties.insert("review".into(), json!({ "type": "boolean" }));
1398
1399        serde_json::Value::Object(serde_json::Map::from_iter([
1400            ("type".into(), json!("object")),
1401            ("properties".into(), serde_json::Value::Object(properties)),
1402            ("required".into(), json!(["action"])),
1403        ]))
1404    }
1405    fn is_readonly(&self) -> bool {
1406        false
1407    }
1408
1409    async fn execute(
1410        &self,
1411        _call_id: &str,
1412        params: serde_json::Value,
1413        ctx: ToolContext,
1414    ) -> Result<ToolOutput> {
1415        let action = params["action"]
1416            .as_str()
1417            .ok_or_else(|| crate::error::Error::Tool("missing 'action' parameter".into()))?;
1418
1419        let mode = ctx.mode;
1420
1421        if !mode.allows_mana_action(action) {
1422            let mode_name = format!("{mode:?}").to_lowercase();
1423            return Ok(ToolOutput::error(format!(
1424                "Mana action '{action}' is not available in {mode_name} mode"
1425            )));
1426        }
1427
1428        let mana_dir = resolve_mana_dir(&ctx.cwd, &params).map_err(crate::error::Error::Tool)?;
1429
1430        match action {
1431            "status" => match mana_core::api::get_status(&mana_dir) {
1432                Ok(status) => Ok(json_output(&status)),
1433                Err(e) => Ok(ToolOutput::error(e.to_string())),
1434            },
1435            "list" => {
1436                let list_params = mana_core::ops::list::ListParams {
1437                    status: params["status"].as_str().map(|s| s.to_string()),
1438                    priority: params["priority"].as_u64().map(|p| p as u8),
1439                    parent: params["parent"].as_str().map(|s| s.to_string()),
1440                    label: params["label"].as_str().map(|s| s.to_string()),
1441                    assignee: None,
1442                    current_user: None,
1443                    include_closed: params["all"].as_bool().unwrap_or(false),
1444                };
1445                match mana_core::api::list_units(&mana_dir, &list_params) {
1446                    Ok(entries) => Ok(json_output(&entries)),
1447                    Err(e) => {
1448                        let message = format!("mana run failed: {e}");
1449                        ctx.ui
1450                            .set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
1451                            .await;
1452                        ctx.ui.set_status("mana", Some(&message)).await;
1453                        Ok(ToolOutput::error(e.to_string()))
1454                    },
1455                }
1456            }
1457            "show" => {
1458                let id = params["id"]
1459                    .as_str()
1460                    .ok_or_else(|| crate::error::Error::Tool("show requires 'id'".into()))?;
1461                match mana_core::ops::show::get(&mana_dir, id) {
1462                    Ok(result) => Ok(json_output(&result.unit)),
1463                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1464                }
1465            }
1466            "create" => {
1467                let title = params["title"]
1468                    .as_str()
1469                    .ok_or_else(|| crate::error::Error::Tool("create requires 'title'".into()))?;
1470                let dependencies = parse_csv_strings(&params["deps"], "deps")?;
1471                let labels = parse_csv_strings(&params["labels"], "labels")?;
1472                let produces = parse_csv_strings(&params["produces"], "produces")?;
1473                let requires = parse_csv_strings(&params["requires"], "requires")?;
1474                let paths = parse_csv_strings(&params["paths"], "paths")?;
1475                let decisions = parse_csv_strings(&params["decisions"], "decisions")?;
1476                let on_fail = parse_on_fail(&params["on_fail"])?;
1477                let kind = parse_unit_kind(&params["kind"])?;
1478
1479                let create_params = mana_core::ops::create::CreateParams {
1480                    title: title.to_string(),
1481                    description: parse_optional_string(&params["description"]),
1482                    acceptance: parse_optional_string(&params["acceptance"]),
1483                    notes: parse_optional_string(&params["notes"]),
1484                    design: parse_optional_string(&params["design"]),
1485                    verify: parse_optional_string(&params["verify"]),
1486                    priority: params["priority"].as_u64().map(|p| p as u8),
1487                    labels,
1488                    assignee: parse_optional_string(&params["assignee"]),
1489                    dependencies,
1490                    parent: parse_optional_string(&params["parent"]),
1491                    produces,
1492                    requires,
1493                    paths,
1494                    on_fail,
1495                    fail_first: params["fail_first"].as_bool().unwrap_or(false),
1496                    feature: params["feature"].as_bool().unwrap_or(false),
1497                    kind,
1498                    verify_timeout: params["verify_timeout"].as_u64(),
1499                    decisions,
1500                    force: params["force"].as_bool().unwrap_or(false),
1501                };
1502                match mana_core::api::create_unit(&mana_dir, create_params) {
1503                    Ok(result) => {
1504                        let unit_value = serde_json::to_value(&result.unit)
1505                            .unwrap_or(serde_json::Value::Null);
1506                        let summary = unit_delta_label(&unit_value)
1507                            .map(|label| format!("mana delta: created {label}"))
1508                            .unwrap_or_else(|| "mana delta: created unit".to_string());
1509                        let detail = parse_optional_string(&params["parent"])
1510                            .map(|parent| format!("parent {parent}"));
1511                        set_mana_delta_widget(&ctx, summary.clone(), detail).await;
1512                        Ok(text_output(
1513                            summary,
1514                            json!({
1515                                "action": "create",
1516                                "title": title,
1517                                "description": params["description"],
1518                                "verify": params["verify"],
1519                                "priority": params["priority"],
1520                                "parent": params["parent"],
1521                                "deps": params["deps"],
1522                                "labels": params["labels"],
1523                                "unit": unit_value,
1524                                "path": result.path,
1525                            }),
1526                        ))
1527                    }
1528                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1529                }
1530            }
1531            "claim" => {
1532                let id = params["id"]
1533                    .as_str()
1534                    .ok_or_else(|| crate::error::Error::Tool("claim requires 'id'".into()))?;
1535                let claim_params = ClaimParams {
1536                    by: params["by"].as_str().map(|s| s.to_string()),
1537                    force: params["force"].as_bool().unwrap_or(true),
1538                };
1539                match mana_core::api::claim_unit(&mana_dir, id, claim_params) {
1540                    Ok(result) => Ok(claim_output(&result)),
1541                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1542                }
1543            }
1544            "release" => {
1545                let id = params["id"]
1546                    .as_str()
1547                    .ok_or_else(|| crate::error::Error::Tool("release requires 'id'".into()))?;
1548                match mana_core::api::release_unit(&mana_dir, id) {
1549                    Ok(result) => Ok(release_output(&result)),
1550                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1551                }
1552            }
1553            "close" => {
1554                let id = params["id"]
1555                    .as_str()
1556                    .ok_or_else(|| crate::error::Error::Tool("close requires 'id'".into()))?;
1557                let opts = mana_core::ops::close::CloseOpts {
1558                    reason: params["reason"].as_str().map(|s| s.to_string()),
1559                    force: params["force"].as_bool().unwrap_or(false),
1560                    defer_verify: false,
1561                };
1562                match mana_core::api::close_unit(&mana_dir, id, opts) {
1563                    Ok(outcome) => {
1564                        let details = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
1565                        if let Some(unit) = details.get("unit") {
1566                            let summary = unit_delta_label(unit)
1567                                .map(|label| format!("mana delta: closed {label}"))
1568                                .unwrap_or_else(|| format!("mana delta: closed {id}"));
1569                            set_mana_delta_widget(
1570                                &ctx,
1571                                summary,
1572                                params["reason"].as_str().map(|s| s.to_string()),
1573                            )
1574                            .await;
1575                        }
1576                        let mut details_obj = details.as_object().cloned().unwrap_or_default();
1577                        details_obj.insert("action".into(), json!("close"));
1578                        if let Some(reason) = params["reason"].as_str() {
1579                            details_obj.insert("reason".into(), json!(reason));
1580                        }
1581                        Ok(text_output(
1582                            format!("Closed unit {id}"),
1583                            serde_json::Value::Object(details_obj),
1584                        ))
1585                    }
1586                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1587                }
1588            }
1589            "update" => {
1590                let id = params["id"]
1591                    .as_str()
1592                    .ok_or_else(|| crate::error::Error::Tool("update requires 'id'".into()))?;
1593                let decisions = parse_csv_strings(&params["decisions"], "decisions")?;
1594                let resolve_decisions =
1595                    parse_csv_strings(&params["resolve_decisions"], "resolve_decisions")?;
1596                let update_params = mana_core::ops::update::UpdateParams {
1597                    title: parse_optional_string(&params["title"]),
1598                    description: parse_optional_string(&params["description"]),
1599                    acceptance: parse_optional_string(&params["acceptance"]),
1600                    notes: parse_optional_string(&params["notes"]),
1601                    design: parse_optional_string(&params["design"]),
1602                    status: parse_optional_string(&params["status"]),
1603                    priority: params["priority"].as_u64().map(|p| p as u8),
1604                    assignee: parse_optional_string(&params["assignee"]),
1605                    add_label: parse_optional_string(&params["add_label"]),
1606                    remove_label: parse_optional_string(&params["remove_label"]),
1607                    decisions,
1608                    resolve_decisions,
1609                };
1610                match mana_core::api::update_unit(&mana_dir, id, update_params) {
1611                    Ok(result) => {
1612                        let unit_value = serde_json::to_value(&result.unit)
1613                            .unwrap_or(serde_json::Value::Null);
1614                        let summary = unit_delta_label(&unit_value)
1615                            .map(|label| format!("mana delta: updated {label}"))
1616                            .unwrap_or_else(|| format!("mana delta: updated {id}"));
1617                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
1618                        Ok(text_output(
1619                            summary,
1620                            json!({
1621                                "action": "update",
1622                                "id": id,
1623                                "status": params["status"],
1624                                "title": params["title"],
1625                                "description": params["description"],
1626                                "priority": params["priority"],
1627                                "notes": params["notes"],
1628                                "acceptance": params["acceptance"],
1629                                "add_label": params["add_label"],
1630                                "remove_label": params["remove_label"],
1631                                "decisions": params["decisions"],
1632                                "resolve_decisions": params["resolve_decisions"],
1633                                "unit": unit_value,
1634                                "path": result.path,
1635                            }),
1636                        ))
1637                    }
1638                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1639                }
1640            }
1641            "notes_append" => {
1642                let id = params["id"]
1643                    .as_str()
1644                    .ok_or_else(|| crate::error::Error::Tool("notes_append requires 'id'".into()))?;
1645                let note = parse_optional_string(&params["notes"])
1646                    .ok_or_else(|| crate::error::Error::Tool("notes_append requires 'notes'".into()))?;
1647                let update_params = mana_core::ops::update::UpdateParams {
1648                    title: None,
1649                    description: None,
1650                    acceptance: None,
1651                    notes: Some(note),
1652                    design: None,
1653                    status: None,
1654                    priority: None,
1655                    assignee: None,
1656                    add_label: None,
1657                    remove_label: None,
1658                    decisions: Vec::new(),
1659                    resolve_decisions: Vec::new(),
1660                };
1661                match mana_core::api::update_unit(&mana_dir, id, update_params) {
1662                    Ok(result) => {
1663                        let unit_value = serde_json::to_value(&result.unit)
1664                            .unwrap_or(serde_json::Value::Null);
1665                        let summary = unit_delta_label(&unit_value)
1666                            .map(|label| format!("mana delta: notes appended on {label}"))
1667                            .unwrap_or_else(|| format!("mana delta: notes appended on {id}"));
1668                        set_mana_delta_widget(&ctx, summary.clone(), Some("notes appended".into())).await;
1669                        Ok(text_output(
1670                            summary,
1671                            json!({
1672                                "action": "notes_append",
1673                                "id": id,
1674                                "notes": params["notes"],
1675                                "unit": unit_value,
1676                                "path": result.path,
1677                            }),
1678                        ))
1679                    }
1680                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1681                }
1682            }
1683            "decision_add" => {
1684                let id = params["id"]
1685                    .as_str()
1686                    .ok_or_else(|| crate::error::Error::Tool("decision_add requires 'id'".into()))?;
1687                let decision = parse_optional_string(&params["description"])
1688                    .or_else(|| parse_optional_string(&params["notes"]))
1689                    .ok_or_else(|| crate::error::Error::Tool("decision_add requires 'description' or 'notes'".into()))?;
1690                let update_params = mana_core::ops::update::UpdateParams {
1691                    title: None,
1692                    description: None,
1693                    acceptance: None,
1694                    notes: None,
1695                    design: None,
1696                    status: None,
1697                    priority: None,
1698                    assignee: None,
1699                    add_label: None,
1700                    remove_label: None,
1701                    decisions: vec![decision],
1702                    resolve_decisions: Vec::new(),
1703                };
1704                match mana_core::api::update_unit(&mana_dir, id, update_params) {
1705                    Ok(result) => {
1706                        let unit_value = serde_json::to_value(&result.unit)
1707                            .unwrap_or(serde_json::Value::Null);
1708                        let summary = unit_delta_label(&unit_value)
1709                            .map(|label| format!("mana delta: decision added on {label}"))
1710                            .unwrap_or_else(|| format!("mana delta: decision added on {id}"));
1711                        set_mana_delta_widget(&ctx, summary.clone(), Some("decision added".into())).await;
1712                        Ok(text_output(
1713                            summary,
1714                            json!({
1715                                "action": "decision_add",
1716                                "id": id,
1717                                "description": params["description"],
1718                                "unit": unit_value,
1719                                "path": result.path,
1720                            }),
1721                        ))
1722                    }
1723                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1724                }
1725            }
1726            "decision_resolve" => {
1727                let id = params["id"]
1728                    .as_str()
1729                    .ok_or_else(|| crate::error::Error::Tool("decision_resolve requires 'id'".into()))?;
1730                let resolve_decisions = parse_csv_strings(&params["resolve_decisions"], "resolve_decisions")?;
1731                if resolve_decisions.is_empty() {
1732                    return Ok(ToolOutput::error(
1733                        "decision_resolve requires 'resolve_decisions'",
1734                    ));
1735                }
1736                let update_params = mana_core::ops::update::UpdateParams {
1737                    title: None,
1738                    description: None,
1739                    acceptance: None,
1740                    notes: None,
1741                    design: None,
1742                    status: None,
1743                    priority: None,
1744                    assignee: None,
1745                    add_label: None,
1746                    remove_label: None,
1747                    decisions: Vec::new(),
1748                    resolve_decisions,
1749                };
1750                match mana_core::api::update_unit(&mana_dir, id, update_params) {
1751                    Ok(result) => {
1752                        let unit_value = serde_json::to_value(&result.unit)
1753                            .unwrap_or(serde_json::Value::Null);
1754                        let summary = unit_delta_label(&unit_value)
1755                            .map(|label| format!("mana delta: decision resolved on {label}"))
1756                            .unwrap_or_else(|| format!("mana delta: decision resolved on {id}"));
1757                        set_mana_delta_widget(&ctx, summary.clone(), Some("decision resolved".into())).await;
1758                        Ok(text_output(
1759                            summary,
1760                            json!({
1761                                "action": "decision_resolve",
1762                                "id": id,
1763                                "resolve_decisions": params["resolve_decisions"],
1764                                "unit": unit_value,
1765                                "path": result.path,
1766                            }),
1767                        ))
1768                    }
1769                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1770                }
1771            }
1772            "reopen" => {
1773                let id = params["id"]
1774                    .as_str()
1775                    .ok_or_else(|| crate::error::Error::Tool("reopen requires 'id'".into()))?;
1776                match mana_core::api::reopen_unit(&mana_dir, id) {
1777                    Ok(result) => {
1778                        let summary = format!("mana delta: reopened {} ({})", result.unit.id, result.unit.title);
1779                        set_mana_delta_widget(&ctx, summary, Some("status=open".into())).await;
1780                        Ok(text_output(
1781                            format!("Reopened unit {} ({})", result.unit.id, result.unit.title),
1782                            json!({
1783                                "action": "reopen",
1784                                "unit": {
1785                                    "id": result.unit.id,
1786                                    "title": result.unit.title,
1787                                    "status": result.unit.status,
1788                                },
1789                                "path": result.path,
1790                            }),
1791                        ))
1792                    }
1793                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1794                }
1795            }
1796            "verify" => {
1797                let id = params["id"]
1798                    .as_str()
1799                    .ok_or_else(|| crate::error::Error::Tool("verify requires 'id'".into()))?;
1800                match mana_core::api::run_verify(&mana_dir, id) {
1801                    Ok(Some(result)) => Ok(text_output(
1802                        format!(
1803                            "Verify {} for unit {id}{}",
1804                            if result.passed { "passed" } else { "failed" },
1805                            result
1806                                .exit_code
1807                                .map(|code| format!(" (exit {code})"))
1808                                .unwrap_or_default()
1809                        ),
1810                        json!({
1811                            "passed": result.passed,
1812                            "exit_code": result.exit_code,
1813                            "stdout": result.stdout,
1814                            "stderr": result.stderr,
1815                            "timed_out": result.timed_out,
1816                            "command": result.command,
1817                            "timeout_secs": result.timeout_secs,
1818                            "unit_id": id,
1819                        }),
1820                    )),
1821                    Ok(None) => Ok(ToolOutput::text(format!("Unit {id} has no verify command."))),
1822                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1823                }
1824            }
1825            "fail" => {
1826                let id = params["id"]
1827                    .as_str()
1828                    .ok_or_else(|| crate::error::Error::Tool("fail requires 'id'".into()))?;
1829                match mana_core::api::fail_unit(&mana_dir, id, parse_optional_string(&params["reason"])) {
1830                    Ok(unit) => {
1831                        let unit_value = serde_json::to_value(&unit)
1832                            .unwrap_or(serde_json::Value::Null);
1833                        let summary = unit_delta_label(&unit_value)
1834                            .map(|label| format!("mana delta: marked failed {label}"))
1835                            .unwrap_or_else(|| format!("mana delta: marked failed {id}"));
1836                        set_mana_delta_widget(
1837                            &ctx,
1838                            summary,
1839                            params["reason"].as_str().map(|s| s.to_string()),
1840                        )
1841                        .await;
1842                        Ok(text_output(
1843                            format!("Marked unit {id} as failed"),
1844                            json!({
1845                                "action": "fail",
1846                                "id": id,
1847                                "reason": params["reason"],
1848                                "unit": unit_value,
1849                            }),
1850                        ))
1851                    }
1852                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1853                }
1854            }
1855            "delete" => {
1856                let id = params["id"]
1857                    .as_str()
1858                    .ok_or_else(|| crate::error::Error::Tool("delete requires 'id'".into()))?;
1859                match mana_core::api::delete_unit(&mana_dir, id) {
1860                    Ok(result) => {
1861                        let summary = format!("mana delta: deleted {} ({})", result.id, result.title);
1862                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
1863                        Ok(text_output(
1864                            format!("Deleted unit {} ({})", result.id, result.title),
1865                            json!({ "action": "delete", "id": result.id, "title": result.title }),
1866                        ))
1867                    }
1868                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1869                }
1870            }
1871            "dep_add" => {
1872                let from_id = params["from_id"]
1873                    .as_str()
1874                    .ok_or_else(|| crate::error::Error::Tool("dep_add requires 'from_id'".into()))?;
1875                let dep_id = params["dep_id"]
1876                    .as_str()
1877                    .ok_or_else(|| crate::error::Error::Tool("dep_add requires 'dep_id'".into()))?;
1878                match mana_core::api::add_dep(&mana_dir, from_id, dep_id) {
1879                    Ok(result) => {
1880                        let summary = format!("mana delta: dependency added {} -> {}", result.from_id, result.to_id);
1881                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
1882                        Ok(text_output(
1883                            format!("Added dependency: {} depends on {}", result.from_id, result.to_id),
1884                            json!({ "action": "dep_add", "from_id": result.from_id, "dep_id": result.to_id }),
1885                        ))
1886                    }
1887                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1888                }
1889            }
1890            "dep_remove" => {
1891                let from_id = params["from_id"]
1892                    .as_str()
1893                    .ok_or_else(|| crate::error::Error::Tool("dep_remove requires 'from_id'".into()))?;
1894                let dep_id = params["dep_id"]
1895                    .as_str()
1896                    .ok_or_else(|| crate::error::Error::Tool("dep_remove requires 'dep_id'".into()))?;
1897                match mana_core::api::remove_dep(&mana_dir, from_id, dep_id) {
1898                    Ok(result) => {
1899                        let summary = format!("mana delta: dependency removed {} -> {}", result.from_id, result.to_id);
1900                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
1901                        Ok(text_output(
1902                            format!("Removed dependency: {} no longer depends on {}", result.from_id, result.to_id),
1903                            json!({ "action": "dep_remove", "from_id": result.from_id, "dep_id": result.to_id }),
1904                        ))
1905                    }
1906                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1907                }
1908            }
1909            "fact_create" => {
1910                let title = parse_optional_string(&params["fact_title"])
1911                    .or_else(|| parse_optional_string(&params["title"]))
1912                    .ok_or_else(|| crate::error::Error::Tool("fact_create requires 'fact_title' or 'title'".into()))?;
1913                let verify = parse_optional_string(&params["verify"])
1914                    .ok_or_else(|| crate::error::Error::Tool("fact_create requires 'verify'".into()))?;
1915                let paths_csv = parse_optional_string(&params["paths_csv"])
1916                    .or_else(|| {
1917                        let paths = parse_csv_strings(&params["paths"], "paths").ok()?;
1918                        if paths.is_empty() { None } else { Some(paths.join(",")) }
1919                    });
1920                let fact_params = mana_core::ops::fact::FactParams {
1921                    title,
1922                    verify,
1923                    description: parse_optional_string(&params["description"]),
1924                    paths: paths_csv,
1925                    ttl_days: params["ttl_days"].as_i64(),
1926                    pass_ok: params["pass_ok"].as_bool().unwrap_or(true),
1927                };
1928                match mana_core::api::create_fact(&mana_dir, fact_params) {
1929                    Ok(result) => {
1930                        let summary = format!("mana delta: created fact {} ({})", result.unit_id, result.unit.title);
1931                        set_mana_delta_widget(&ctx, summary.clone(), Some("fact".into())).await;
1932                        Ok(text_output(
1933                            format!("Created fact {} ({})", result.unit_id, result.unit.title),
1934                            json!({
1935                                "action": "fact_create",
1936                                "unit_id": result.unit_id,
1937                                "unit": {
1938                                    "id": result.unit.id,
1939                                    "title": result.unit.title,
1940                                    "unit_type": result.unit.unit_type,
1941                                    "verify": result.unit.verify,
1942                                    "paths": result.unit.paths,
1943                                    "stale_after": result.unit.stale_after,
1944                                }
1945                            }),
1946                        ))
1947                    }
1948                    Err(e) => Ok(ToolOutput::error(e.to_string())),
1949                }
1950            }
1951            "fact_verify" => match mana_core::api::verify_facts(&mana_dir) {
1952                Ok(result) => Ok(text_output(
1953                    format!(
1954                        "Verified {}/{} facts · {} stale · {} failing · {} suspect",
1955                        result.verified_count,
1956                        result.total_facts,
1957                        result.stale_count,
1958                        result.failing_count,
1959                        result.suspect_count
1960                    ),
1961                    json!({
1962                        "total_facts": result.total_facts,
1963                        "verified_count": result.verified_count,
1964                        "stale_count": result.stale_count,
1965                        "failing_count": result.failing_count,
1966                        "suspect_count": result.suspect_count,
1967                    }),
1968                )),
1969                Err(e) => Ok(ToolOutput::error(e.to_string())),
1970            },
1971            "logs" => {
1972                if let Some(run_id) = params["run_id"].as_str() {
1973                    if let Some(state) = run_state_snapshot(&self.run_store, Some(run_id)) {
1974                        let text = if state.log_lines.is_empty() {
1975                            format!(
1976                                "No native stream events captured yet for run {}.",
1977                                state.run_id
1978                            )
1979                        } else {
1980                            truncate_with_note(&state.log_lines.join("\n"))
1981                        };
1982                        return Ok(text_output(
1983                            text,
1984                            serde_json::to_value(&state).unwrap_or(serde_json::Value::Null),
1985                        ));
1986                    }
1987                    return Ok(ToolOutput::error(format!(
1988                        "Unknown native mana run_id: {run_id}"
1989                    )));
1990                }
1991
1992                let id = params["id"]
1993                    .as_str()
1994                    .ok_or_else(|| crate::error::Error::Tool("logs requires 'id' or 'run_id'".into()))?;
1995                match find_all_logs(id) {
1996                    Ok(paths) if paths.is_empty() => Ok(ToolOutput::text(format!(
1997                        "No logs for unit {id}. Has it been dispatched with mana run?"
1998                    ))),
1999                    Ok(paths) => {
2000                        let mut sections = Vec::new();
2001                        for path in &paths {
2002                            let filename = path
2003                                .file_name()
2004                                .and_then(|n| n.to_str())
2005                                .unwrap_or("unknown");
2006                            let body = std::fs::read_to_string(path)
2007                                .unwrap_or_else(|e| format!("(error reading {}: {e})", path.display()));
2008                            sections.push(format!("═══ {filename} ═══\n\n{body}"));
2009                        }
2010                        let text = truncate_with_note(&sections.join("\n\n"));
2011                        Ok(text_output(text, json!({ "unit_id": id, "logs": paths })))
2012                    }
2013                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2014                }
2015            }
2016            "agents" => match load_agents() {
2017                Ok(agents) => Ok(json_output(&agents)),
2018                Err(e) => Ok(ToolOutput::error(e.to_string())),
2019            },
2020            "run_state" | "evaluate" => {
2021                let run_id = params["run_id"].as_str();
2022                match run_state_snapshot(&self.run_store, run_id) {
2023                    Some(state) => {
2024                        if action == "evaluate" {
2025                            Ok(evaluate_run_output(&state))
2026                        } else {
2027                            Ok(run_state_output(&state))
2028                        }
2029                    }
2030                    None => {
2031                        let which = run_id.unwrap_or("latest");
2032                        Ok(ToolOutput::error(format!(
2033                            "No native mana run state available for {which}. Start one with mana(action=\"run\")."
2034                        )))
2035                    }
2036                }
2037            }
2038            "next" => {
2039                let count = params["count"].as_u64().unwrap_or(1).max(1) as usize;
2040                match mana_core::api::load_index(&mana_dir) {
2041                    Ok(index) => {
2042                        let ready: Vec<&mana_core::index::IndexEntry> = index
2043                            .units
2044                            .iter()
2045                            .filter(|e| {
2046                                e.status == mana_core::unit::Status::Open
2047                                    && e.has_verify
2048                                    && !e.feature
2049                                    && mana_core::blocking::check_blocked(e, &index).is_none()
2050                                    && !index.units.iter().any(|child| {
2051                                        child.parent.as_deref() == Some(e.id.as_str())
2052                                            && child.status != mana_core::unit::Status::Closed
2053                                    })
2054                            })
2055                            .collect();
2056
2057                        let mut reverse_deps: std::collections::HashMap<String, Vec<String>> =
2058                            std::collections::HashMap::new();
2059                        for entry in &index.units {
2060                            for dep_id in &entry.dependencies {
2061                                reverse_deps
2062                                    .entry(dep_id.clone())
2063                                    .or_default()
2064                                    .push(entry.id.clone());
2065                            }
2066                        }
2067
2068                        fn count_transitive_unblocks(
2069                            unit_id: &str,
2070                            reverse_deps: &std::collections::HashMap<String, Vec<String>>,
2071                        ) -> usize {
2072                            let mut visited = std::collections::HashSet::new();
2073                            let mut stack = vec![unit_id.to_string()];
2074                            while let Some(current) = stack.pop() {
2075                                if let Some(dependents) = reverse_deps.get(&current) {
2076                                    for dep in dependents {
2077                                        if visited.insert(dep.clone()) {
2078                                            stack.push(dep.clone());
2079                                        }
2080                                    }
2081                                }
2082                            }
2083                            visited.len()
2084                        }
2085
2086                        fn score_unit(entry: &mana_core::index::IndexEntry, unblock_count: usize) -> f64 {
2087                            let priority_score =
2088                                (5u8.saturating_sub(entry.priority)) as f64 * 10.0;
2089                            let unblock_score = (unblock_count as f64 * 5.0).min(50.0);
2090                            let age_days = std::time::SystemTime::now()
2091                                .duration_since(std::time::UNIX_EPOCH)
2092                                .unwrap_or_default()
2093                                .as_secs()
2094                                / 86_400;
2095                            let created_days = entry.created_at.timestamp().max(0) as u64 / 86_400;
2096                            let age_days = age_days.saturating_sub(created_days) as f64;
2097                            let age_score = age_days.min(30.0);
2098                            let attempt_penalty = (entry.attempts as f64 * 3.0).min(15.0);
2099                            priority_score + unblock_score + age_score - attempt_penalty
2100                        }
2101
2102                        let mut scored: Vec<ScoredUnit> = ready
2103                            .iter()
2104                            .map(|entry| {
2105                                let transitive_count =
2106                                    count_transitive_unblocks(&entry.id, &reverse_deps);
2107                                let unblocks = reverse_deps
2108                                    .get(&entry.id)
2109                                    .cloned()
2110                                    .unwrap_or_default();
2111                                let score = score_unit(entry, transitive_count);
2112                                let now_days = std::time::SystemTime::now()
2113                                    .duration_since(std::time::UNIX_EPOCH)
2114                                    .unwrap_or_default()
2115                                    .as_secs()
2116                                    / 86_400;
2117                                let created_days = entry.created_at.timestamp().max(0) as u64 / 86_400;
2118                                let age_days = now_days.saturating_sub(created_days);
2119                                ScoredUnit {
2120                                    id: entry.id.clone(),
2121                                    title: entry.title.clone(),
2122                                    priority: entry.priority,
2123                                    score,
2124                                    unblocks,
2125                                    age_days,
2126                                    attempts: entry.attempts,
2127                                }
2128                            })
2129                            .collect();
2130
2131                        scored.sort_by(|a, b| {
2132                            b.score
2133                                .partial_cmp(&a.score)
2134                                .unwrap_or(std::cmp::Ordering::Equal)
2135                        });
2136                        scored.truncate(count);
2137                        Ok(text_output(
2138                            scored_units_to_text(&scored),
2139                            serde_json::to_value(&scored)
2140                                .unwrap_or(serde_json::Value::Null),
2141                        ))
2142                    }
2143                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2144                }
2145            }
2146            "tree" => {
2147                let id = params["id"].as_str();
2148                let lines = if let Some(root_id) = id {
2149                    match mana_core::api::get_tree(&mana_dir, root_id) {
2150                        Ok(tree) => {
2151                            let mut lines = Vec::new();
2152                            tree_lines(&tree, 0, &mut lines);
2153                            lines
2154                        }
2155                        Err(tree_err) => match mana_core::ops::show::get(&mana_dir, root_id) {
2156                            Ok(result) if result.unit.is_archived => {
2157                                return Ok(ToolOutput::error(format!(
2158                                    "Archived unit {root_id} can be shown but not rendered in tree view. Tree only includes active units."
2159                                )));
2160                            }
2161                            Ok(_) | Err(_) => return Ok(ToolOutput::error(tree_err.to_string())),
2162                        },
2163                    }
2164                } else {
2165                    match mana_core::api::load_index(&mana_dir) {
2166                        Ok(index) => {
2167                            let roots: Vec<_> = index
2168                                .units
2169                                .iter()
2170                                .filter(|entry| entry.parent.is_none())
2171                                .map(|entry| entry.id.clone())
2172                                .collect();
2173                            let mut lines = Vec::new();
2174                            for (idx, root_id) in roots.iter().enumerate() {
2175                                match mana_core::api::get_tree(&mana_dir, root_id) {
2176                                    Ok(tree) => {
2177                                        if idx > 0 {
2178                                            lines.push(String::new());
2179                                        }
2180                                        tree_lines(&tree, 0, &mut lines);
2181                                    }
2182                                    Err(e) => return Ok(ToolOutput::error(e.to_string())),
2183                                }
2184                            }
2185                            lines
2186                        }
2187                        Err(e) => return Ok(ToolOutput::error(e.to_string())),
2188                    }
2189                };
2190                let text = if lines.is_empty() {
2191                    "(no units)".to_string()
2192                } else {
2193                    truncate_with_note(&lines.join("\n"))
2194                };
2195                Ok(text_output(text, json!({ "root": id })))
2196            }
2197            "run" => {
2198                let run_params = NativeRunParams {
2199                    target: target_from_params(&params)?,
2200                    jobs: params["jobs"].as_u64().unwrap_or(4) as u32,
2201                    dry_run: params["dry_run"].as_bool().unwrap_or(false),
2202                    loop_mode: params["loop"].as_bool().unwrap_or(false),
2203                    keep_going: params["keep_going"].as_bool().unwrap_or(false),
2204                    timeout: params["timeout"].as_u64().unwrap_or(30) as u32,
2205                    idle_timeout: params["idle_timeout"].as_u64().unwrap_or(5) as u32,
2206                    json_stream: true,
2207                    review: params["review"].as_bool().unwrap_or(false),
2208                };
2209                let background = params["background"].as_bool().unwrap_or(!run_params.dry_run);
2210                let scope = scope_from_target(&run_params.target);
2211                let run_id = {
2212                    let mut store = self.run_store.lock().map_err(|_| {
2213                        crate::error::Error::Tool("mana run state lock poisoned".into())
2214                    })?;
2215                    let run_id = store.start_run(scope.clone(), background, &run_params);
2216                    store.persist();
2217                    run_id
2218                };
2219
2220                if background {
2221                    let started = background_run_started_output(&scope, &run_id, &run_params);
2222                    spawn_background_run(
2223                        mana_dir.clone(),
2224                        run_params,
2225                        ctx,
2226                        self.run_store.clone(),
2227                        run_id,
2228                    );
2229                    return Ok(started);
2230                }
2231
2232                send_update(
2233                    &ctx,
2234                    format!("Starting mana run {run_id}..."),
2235                    json!({"kind": "mana_run_status", "status": "starting", "run_id": run_id, "scope": scope}),
2236                );
2237                ctx.ui
2238                    .set_widget(
2239                        "mana",
2240                        Some(mana_widget_lines(
2241                            format!("running mana ({run_id})"),
2242                            Some(format!("native foreground orchestration · {scope}")),
2243                        )),
2244                    )
2245                    .await;
2246                ctx.ui.set_status("mana", Some("mana: running")).await;
2247
2248                let run_store = self.run_store.clone();
2249                let run_id_for_sink = run_id.clone();
2250                let update_tx = ctx.update_tx.clone();
2251                match mana::commands::run::run_with_stream_capture_and_sink(
2252                    &mana_dir,
2253                    run_params,
2254                    Some(Arc::new(move |event| {
2255                        update_run_store_with_event(&run_store, &run_id_for_sink, &event);
2256                        if let Some(line) = stream_event_line(&event) {
2257                            let _ = update_tx.try_send(ToolUpdate {
2258                                content: vec![imp_llm::ContentBlock::Text { text: line }],
2259                                details: serde_json::to_value(&event)
2260                                    .unwrap_or(serde_json::Value::Null),
2261                            });
2262                        }
2263                    })),
2264                ) {
2265                    Ok(view) => {
2266                        finish_run_in_store(&self.run_store, &run_id, &view);
2267                        for line in run_summary_lines(&view) {
2268                            send_update(
2269                                &ctx,
2270                                line,
2271                                json!({"kind": "mana_run_view", "run_id": run_id, "scope": scope, "view": view}),
2272                            );
2273                        }
2274                        let summary = format!(
2275                            "mana finished · {} done · {} failed",
2276                            view.summary.total_closed, view.summary.total_failed
2277                        );
2278                        ctx.ui
2279                            .set_widget("mana", Some(mana_widget_lines(summary.clone(), Some(scope.clone()))))
2280                            .await;
2281                        ctx.ui.set_status("mana", Some(&summary)).await;
2282                        Ok(ToolOutput {
2283                            content: run_summary_lines(&view)
2284                                .into_iter()
2285                                .map(|text| imp_llm::ContentBlock::Text { text })
2286                                .collect(),
2287                            details: json!({
2288                                "run_id": run_id,
2289                                "scope": scope,
2290                                "runtime": view.runtime,
2291                                "view": serde_json::to_value(&view).unwrap_or(serde_json::Value::Null)
2292                            }),
2293                            is_error: false,
2294                        })
2295                    }
2296                    Err(e) => {
2297                        fail_run_in_store(&self.run_store, &run_id, e.to_string());
2298                        Ok(ToolOutput::error(e.to_string()))
2299                    }
2300                }
2301            }
2302            other => Ok(ToolOutput::error(format!(
2303                "Unknown action: {other}. Use: status, list, show, create, close, update, run, run_state, evaluate, claim, release, logs, agents, next, tree, reopen, verify, fail, delete, dep_add, dep_remove, fact_create, fact_verify, notes_append, decision_add, decision_resolve"
2304            ))),
2305        }
2306    }
2307}
2308
2309#[cfg(test)]
2310mod tests {
2311    use std::sync::Arc;
2312
2313    use async_trait::async_trait;
2314    use serde_json::json;
2315    use tokio::sync::mpsc;
2316
2317    use super::{evaluate_run_output, stream_event_line, ManaRunStore, ManaTool, NativeRunState};
2318    use crate::tools::{FileCache, FileTracker, Tool, ToolContext, ToolUpdate};
2319    use crate::ui::{NotifyLevel, NullInterface, WidgetContent};
2320
2321    enum ManaResult {
2322        ModeBlocked(String),
2323        Attempted(crate::tools::ToolOutput),
2324    }
2325
2326    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2327
2328    struct TestUi {
2329        widgets: Arc<std::sync::Mutex<Vec<(String, Option<WidgetContent>)>>>,
2330    }
2331
2332    #[async_trait]
2333    impl crate::ui::UserInterface for TestUi {
2334        fn has_ui(&self) -> bool {
2335            true
2336        }
2337
2338        async fn notify(&self, _message: &str, _level: NotifyLevel) {}
2339
2340        async fn confirm(&self, _title: &str, _message: &str) -> Option<bool> {
2341            None
2342        }
2343
2344        async fn select_with_context(
2345            &self,
2346            _title: &str,
2347            _context: &str,
2348            _options: &[crate::ui::SelectOption],
2349        ) -> Option<usize> {
2350            None
2351        }
2352
2353        async fn input_with_context(
2354            &self,
2355            _title: &str,
2356            _context: &str,
2357            _placeholder: &str,
2358        ) -> Option<String> {
2359            None
2360        }
2361
2362        async fn set_status(&self, _key: &str, _text: Option<&str>) {}
2363
2364        async fn set_widget(&self, key: &str, content: Option<WidgetContent>) {
2365            self.widgets
2366                .lock()
2367                .unwrap()
2368                .push((key.to_string(), content));
2369        }
2370
2371        async fn custom(&self, _component: crate::ui::ComponentSpec) -> Option<serde_json::Value> {
2372            None
2373        }
2374    }
2375
2376    async fn run_with_mode(mode_name: &str, action: &str) -> ManaResult {
2377        let prev = {
2378            let _guard = ENV_LOCK.lock().unwrap();
2379            let prev = std::env::var("IMP_MODE").ok();
2380            std::env::set_var("IMP_MODE", mode_name);
2381            prev
2382        };
2383
2384        let dir = tempfile::tempdir().unwrap();
2385        let mana_dir = dir.path().join(".mana");
2386        std::fs::create_dir_all(&mana_dir).unwrap();
2387        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2388        std::fs::write(
2389            mana_dir.join("1-test-unit.md"),
2390            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2391        )
2392        .unwrap();
2393        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2394        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2395        let ctx = ToolContext {
2396            cwd: dir.path().to_path_buf(),
2397            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2398            update_tx: tx,
2399            command_tx: cmd_tx,
2400            ui: Arc::new(NullInterface),
2401            file_cache: Arc::new(FileCache::new()),
2402            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2403            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2404            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2405            lua_tool_loader: None,
2406            mode: crate::config::AgentMode::from_name(mode_name)
2407                .unwrap_or(crate::config::AgentMode::Full),
2408            read_max_lines: 500,
2409            turn_mana_review: Arc::new(std::sync::Mutex::new(
2410                crate::mana_review::TurnManaReviewAccumulator::default(),
2411            )),
2412            config: Arc::new(crate::config::Config::default()),
2413        };
2414
2415        let tool = ManaTool::default();
2416        let outcome = tool
2417            .execute("call_1", json!({ "action": action, "id": "1" }), ctx)
2418            .await;
2419
2420        match prev {
2421            Some(v) => {
2422                let _guard = ENV_LOCK.lock().unwrap();
2423                std::env::set_var("IMP_MODE", v)
2424            }
2425            None => {
2426                let _guard = ENV_LOCK.lock().unwrap();
2427                std::env::remove_var("IMP_MODE")
2428            }
2429        }
2430
2431        match outcome {
2432            Err(crate::error::Error::Tool(msg)) => {
2433                ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
2434            }
2435            Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
2436            Ok(output) => {
2437                if output.is_error {
2438                    if let Some(text) = output.text_content() {
2439                        if text.contains("mode") && text.contains(action) {
2440                            return ManaResult::ModeBlocked(text.to_string());
2441                        }
2442                    }
2443                }
2444                ManaResult::Attempted(output)
2445            }
2446        }
2447    }
2448
2449    fn ctx_with_mode(
2450        dir: &std::path::Path,
2451        mode: crate::config::AgentMode,
2452    ) -> (ToolContext, tempfile::TempDir) {
2453        let mana_dir = dir.join(".mana");
2454        std::fs::create_dir_all(&mana_dir).unwrap();
2455        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2456        std::fs::write(
2457            mana_dir.join("1-test-unit.md"),
2458            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2459        )
2460        .unwrap();
2461        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2462        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2463        let ctx = ToolContext {
2464            cwd: dir.to_path_buf(),
2465            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2466            update_tx: tx,
2467            command_tx: cmd_tx,
2468            ui: Arc::new(NullInterface),
2469            file_cache: Arc::new(FileCache::new()),
2470            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2471            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2472            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2473            lua_tool_loader: None,
2474            mode,
2475            read_max_lines: 500,
2476            turn_mana_review: Arc::new(std::sync::Mutex::new(
2477                crate::mana_review::TurnManaReviewAccumulator::default(),
2478            )),
2479            config: Arc::new(crate::config::Config::default()),
2480        };
2481        (ctx, tempfile::tempdir().unwrap())
2482    }
2483
2484    fn ctx_with_ui(
2485        dir: &std::path::Path,
2486        mode: crate::config::AgentMode,
2487    ) -> (
2488        ToolContext,
2489        tempfile::TempDir,
2490        Arc<std::sync::Mutex<Vec<(String, Option<WidgetContent>)>>>,
2491    ) {
2492        let mana_dir = dir.join(".mana");
2493        std::fs::create_dir_all(&mana_dir).unwrap();
2494        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2495        std::fs::write(
2496            mana_dir.join("1-test-unit.md"),
2497            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2498        )
2499        .unwrap();
2500        let widgets = Arc::new(std::sync::Mutex::new(Vec::new()));
2501        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2502        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2503        let ctx = ToolContext {
2504            cwd: dir.to_path_buf(),
2505            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2506            update_tx: tx,
2507            command_tx: cmd_tx,
2508            ui: Arc::new(TestUi {
2509                widgets: widgets.clone(),
2510            }),
2511            file_cache: Arc::new(FileCache::new()),
2512            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2513            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2514            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2515            lua_tool_loader: None,
2516            mode,
2517            read_max_lines: 500,
2518            turn_mana_review: Arc::new(std::sync::Mutex::new(
2519                crate::mana_review::TurnManaReviewAccumulator::default(),
2520            )),
2521            config: Arc::new(crate::config::Config::default()),
2522        };
2523        (ctx, tempfile::tempdir().unwrap(), widgets)
2524    }
2525
2526    async fn run_with_ctx_mode(mode: crate::config::AgentMode, action: &str) -> ManaResult {
2527        let dir = tempfile::tempdir().unwrap();
2528        let (ctx, _keep) = ctx_with_mode(dir.path(), mode);
2529        let tool = ManaTool::default();
2530        let outcome = tool
2531            .execute("call_ctx", json!({ "action": action, "id": "1" }), ctx)
2532            .await;
2533        match outcome {
2534            Err(crate::error::Error::Tool(msg)) => {
2535                ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
2536            }
2537            Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
2538            Ok(output) => {
2539                if output.is_error {
2540                    if let Some(text) = output.text_content() {
2541                        if text.contains("mode") && text.contains(action) {
2542                            return ManaResult::ModeBlocked(text.to_string());
2543                        }
2544                    }
2545                }
2546                ManaResult::Attempted(output)
2547            }
2548        }
2549    }
2550
2551    #[tokio::test]
2552    async fn create_sets_mana_delta_widget_and_action_details() {
2553        let dir = tempfile::tempdir().unwrap();
2554        let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
2555        let tool = ManaTool::default();
2556        let result = tool
2557            .execute(
2558                "call_create_widget",
2559                json!({ "action": "create", "title": "Widget unit", "verify": "test -n ok" }),
2560                ctx,
2561            )
2562            .await
2563            .unwrap();
2564
2565        assert_eq!(result.details["action"], "create");
2566        assert_eq!(result.details["unit"]["title"], "Widget unit");
2567        let widgets = widgets.lock().unwrap();
2568        assert!(widgets.iter().any(|(key, content)| {
2569            key == "mana"
2570                && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: created 2 · Widget unit")))
2571        }));
2572    }
2573
2574    #[tokio::test]
2575    async fn decision_add_sets_mana_delta_widget_and_action_details() {
2576        let dir = tempfile::tempdir().unwrap();
2577        let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
2578        let tool = ManaTool::default();
2579        let result = tool
2580            .execute(
2581                "call_decision_widget",
2582                json!({ "action": "decision_add", "id": "1", "description": "Choose retry limit" }),
2583                ctx,
2584            )
2585            .await
2586            .unwrap();
2587
2588        assert_eq!(result.details["action"], "decision_add");
2589        assert_eq!(result.details["unit"]["decisions"][0], "Choose retry limit");
2590        let widgets = widgets.lock().unwrap();
2591        assert!(widgets.iter().any(|(key, content)| {
2592            key == "mana"
2593                && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: decision added on 1 · Test unit")))
2594        }));
2595    }
2596
2597    #[tokio::test]
2598    async fn worker_blocks_create() {
2599        match run_with_mode("worker", "create").await {
2600            ManaResult::ModeBlocked(_) => {}
2601            ManaResult::Attempted(out) => {
2602                panic!(
2603                    "worker should block 'create', got: {:?}",
2604                    out.text_content()
2605                )
2606            }
2607        }
2608    }
2609
2610    #[tokio::test]
2611    async fn create_supports_rich_unit_fields() {
2612        let dir = tempfile::tempdir().unwrap();
2613        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2614        let tool = ManaTool::default();
2615        let result = tool
2616            .execute(
2617                "call_create_rich",
2618                json!({
2619                    "action": "create",
2620                    "title": "Rich unit",
2621                    "description": "Implement the thing",
2622                    "acceptance": "- works\n- tested",
2623                    "notes": "start here",
2624                    "design": "follow existing pattern",
2625                    "verify": "test -n ok",
2626                    "labels": ["feature", "backend"],
2627                    "deps": ["1"],
2628                    "paths": ["src/lib.rs", "src/auth.rs"],
2629                    "requires": ["auth-api"],
2630                    "produces": ["auth-fix"],
2631                    "decisions": ["Confirm whether auth should stay sync"],
2632                    "feature": true,
2633                    "fail_first": true,
2634                    "verify_timeout": 12,
2635                    "force": false
2636                }),
2637                ctx,
2638            )
2639            .await
2640            .unwrap();
2641        let unit = &result.details["unit"];
2642        assert_eq!(unit["acceptance"], "- works\n- tested");
2643        assert_eq!(unit["labels"][0], "feature");
2644        assert_eq!(unit["dependencies"][0], "1");
2645        assert_eq!(unit["paths"][0], "src/lib.rs");
2646        assert_eq!(unit["requires"][0], "auth-api");
2647        assert_eq!(unit["produces"][0], "auth-fix");
2648        assert_eq!(
2649            unit["decisions"][0],
2650            "Confirm whether auth should stay sync"
2651        );
2652        assert_eq!(unit["feature"], true);
2653        assert_eq!(unit["fail_first"], true);
2654        assert_eq!(unit["verify_timeout"], 12);
2655    }
2656
2657    #[tokio::test]
2658    async fn update_supports_acceptance_labels_and_decisions() {
2659        let dir = tempfile::tempdir().unwrap();
2660        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2661        let tool = ManaTool::default();
2662        let _created = tool
2663            .execute(
2664                "call_create_update_target",
2665                json!({ "action": "create", "title": "Update target", "verify": "test -n ok" }),
2666                ctx,
2667            )
2668            .await
2669            .unwrap();
2670
2671        let dir2 = tempfile::tempdir().unwrap();
2672        let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
2673        std::fs::write(
2674            dir2.path().join(".mana").join("1-test-unit.md"),
2675            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2676        ).unwrap();
2677        let result = tool
2678            .execute(
2679                "call_update_rich",
2680                json!({
2681                    "action": "update",
2682                    "id": "1",
2683                    "acceptance": "must pass auth flow",
2684                    "add_label": "backend",
2685                    "decisions": ["Choose retry limit"],
2686                    "resolve_decisions": []
2687                }),
2688                ctx2,
2689            )
2690            .await
2691            .unwrap();
2692        let unit = &result.details["unit"];
2693        assert_eq!(unit["acceptance"], "must pass auth flow");
2694        assert_eq!(unit["labels"][0], "backend");
2695        assert_eq!(unit["decisions"][0], "Choose retry limit");
2696    }
2697
2698    #[tokio::test]
2699    async fn create_respects_verify_lint_by_default() {
2700        let dir = tempfile::tempdir().unwrap();
2701        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2702        let tool = ManaTool::default();
2703        let result = tool
2704            .execute(
2705                "call_create_lint",
2706                json!({ "action": "create", "title": "Weak verify", "verify": "echo done" }),
2707                ctx,
2708            )
2709            .await
2710            .unwrap();
2711        assert!(result.is_error, "weak verify should be rejected by default");
2712        let text = result.text_content().unwrap_or("");
2713        assert!(text.contains("Verify command has lint errors") || text.contains("verify"));
2714    }
2715
2716    #[tokio::test]
2717    async fn native_verify_reopen_and_fact_actions_work() {
2718        let dir = tempfile::tempdir().unwrap();
2719        let mana_dir = dir.path().join(".mana");
2720        std::fs::create_dir_all(&mana_dir).unwrap();
2721        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2722        std::fs::write(
2723            mana_dir.join("1-test-unit.md"),
2724            "---\nid: '1'\ntitle: Test unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\n---\n\nbody\n",
2725        ).unwrap();
2726        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2727        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2728        let ctx = ToolContext {
2729            cwd: dir.path().to_path_buf(),
2730            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2731            update_tx: tx,
2732            command_tx: cmd_tx,
2733            ui: Arc::new(NullInterface),
2734            file_cache: Arc::new(FileCache::new()),
2735            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2736            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2737            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2738            lua_tool_loader: None,
2739            mode: crate::config::AgentMode::Full,
2740            read_max_lines: 500,
2741            turn_mana_review: Arc::new(std::sync::Mutex::new(
2742                crate::mana_review::TurnManaReviewAccumulator::default(),
2743            )),
2744            config: Arc::new(crate::config::Config::default()),
2745        };
2746        let tool = ManaTool::default();
2747        let reopened = tool
2748            .execute("call_reopen", json!({ "action": "reopen", "id": "1" }), ctx)
2749            .await
2750            .unwrap();
2751        assert_eq!(reopened.details["unit"]["status"], "open");
2752
2753        let dir2 = tempfile::tempdir().unwrap();
2754        let mana_dir2 = dir2.path().join(".mana");
2755        std::fs::create_dir_all(&mana_dir2).unwrap();
2756        std::fs::write(mana_dir2.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2757        std::fs::write(
2758            mana_dir2.join("1-test-unit.md"),
2759            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2760        ).unwrap();
2761        let (tx2, _rx2) = mpsc::channel::<ToolUpdate>(1);
2762        let (cmd_tx2, _cmd_rx2) = mpsc::channel(16);
2763        let ctx2 = ToolContext {
2764            cwd: dir2.path().to_path_buf(),
2765            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2766            update_tx: tx2,
2767            command_tx: cmd_tx2,
2768            ui: Arc::new(NullInterface),
2769            file_cache: Arc::new(FileCache::new()),
2770            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2771            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2772            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2773            lua_tool_loader: None,
2774            mode: crate::config::AgentMode::Full,
2775            read_max_lines: 500,
2776            turn_mana_review: Arc::new(std::sync::Mutex::new(
2777                crate::mana_review::TurnManaReviewAccumulator::default(),
2778            )),
2779            config: Arc::new(crate::config::Config::default()),
2780        };
2781        let verify = tool
2782            .execute(
2783                "call_verify",
2784                json!({ "action": "verify", "id": "1" }),
2785                ctx2,
2786            )
2787            .await
2788            .unwrap();
2789        assert_eq!(verify.details["passed"], true);
2790
2791        let dir3 = tempfile::tempdir().unwrap();
2792        let mana_dir3 = dir3.path().join(".mana");
2793        std::fs::create_dir_all(&mana_dir3).unwrap();
2794        std::fs::write(mana_dir3.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
2795        let (tx3, _rx3) = mpsc::channel::<ToolUpdate>(1);
2796        let (cmd_tx3, _cmd_rx3) = mpsc::channel(16);
2797        let ctx3 = ToolContext {
2798            cwd: dir3.path().to_path_buf(),
2799            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2800            update_tx: tx3,
2801            command_tx: cmd_tx3,
2802            ui: Arc::new(NullInterface),
2803            file_cache: Arc::new(FileCache::new()),
2804            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2805            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2806            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2807            lua_tool_loader: None,
2808            mode: crate::config::AgentMode::Full,
2809            read_max_lines: 500,
2810            turn_mana_review: Arc::new(std::sync::Mutex::new(
2811                crate::mana_review::TurnManaReviewAccumulator::default(),
2812            )),
2813            config: Arc::new(crate::config::Config::default()),
2814        };
2815        let fact = tool.execute("call_fact", json!({ "action": "fact_create", "fact_title": "Auth fact", "verify": "test -d .mana", "description": "fact body", "ttl_days": 7 }), ctx3).await.unwrap();
2816        assert_eq!(fact.details["unit"]["unit_type"], "fact");
2817    }
2818
2819    #[tokio::test]
2820    async fn notes_append_is_safe_partial_update() {
2821        let dir = tempfile::tempdir().unwrap();
2822        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2823        let tool = ManaTool::default();
2824        let result = tool
2825            .execute(
2826                "call_notes_append",
2827                json!({
2828                    "action": "notes_append",
2829                    "id": "1",
2830                    "notes": "diagnosis from turn 2"
2831                }),
2832                ctx,
2833            )
2834            .await
2835            .unwrap();
2836        let unit = &result.details["unit"];
2837        assert_eq!(unit["title"], "Test unit");
2838        assert!(unit["notes"]
2839            .as_str()
2840            .unwrap_or("")
2841            .contains("diagnosis from turn 2"));
2842    }
2843
2844    #[tokio::test]
2845    async fn decision_add_and_resolve_work() {
2846        let dir = tempfile::tempdir().unwrap();
2847        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2848        let tool = ManaTool::default();
2849        let added = tool
2850            .execute(
2851                "call_decision_add",
2852                json!({
2853                    "action": "decision_add",
2854                    "id": "1",
2855                    "description": "Choose retry limit"
2856                }),
2857                ctx,
2858            )
2859            .await
2860            .unwrap();
2861        assert_eq!(added.details["unit"]["decisions"][0], "Choose retry limit");
2862
2863        let dir2 = tempfile::tempdir().unwrap();
2864        let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
2865        std::fs::write(
2866            dir2.path().join(".mana").join("1-test-unit.md"),
2867            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\ndecisions:\n  - Choose retry limit\n---\n\nbody\n",
2868        ).unwrap();
2869        let resolved = tool
2870            .execute(
2871                "call_decision_resolve",
2872                json!({
2873                    "action": "decision_resolve",
2874                    "id": "1",
2875                    "resolve_decisions": ["Choose retry limit"]
2876                }),
2877                ctx2,
2878            )
2879            .await
2880            .unwrap();
2881        let decisions = resolved.details["unit"]["decisions"]
2882            .as_array()
2883            .cloned()
2884            .unwrap_or_default();
2885        assert!(decisions.is_empty());
2886    }
2887
2888    #[tokio::test]
2889    async fn show_returns_archived_unit_when_active_missing() {
2890        let dir = tempfile::tempdir().unwrap();
2891        let mana_dir = dir.path().join(".mana");
2892        std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
2893        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2894        std::fs::write(
2895            mana_dir.join("archive/2026/04/1-archived-unit.md"),
2896            "---\nid: '1'\ntitle: Archived unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\nis_archived: true\n---\n\nbody\n",
2897        )
2898        .unwrap();
2899        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2900        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2901        let ctx = ToolContext {
2902            cwd: dir.path().to_path_buf(),
2903            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2904            update_tx: tx,
2905            command_tx: cmd_tx,
2906            ui: Arc::new(NullInterface),
2907            file_cache: Arc::new(FileCache::new()),
2908            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2909            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2910            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2911            lua_tool_loader: None,
2912            mode: crate::config::AgentMode::Full,
2913            read_max_lines: 500,
2914            turn_mana_review: Arc::new(std::sync::Mutex::new(
2915                crate::mana_review::TurnManaReviewAccumulator::default(),
2916            )),
2917            config: Arc::new(crate::config::Config::default()),
2918        };
2919        let tool = ManaTool::default();
2920        let result = tool
2921            .execute(
2922                "call_show_archived",
2923                json!({ "action": "show", "id": "1" }),
2924                ctx,
2925            )
2926            .await
2927            .unwrap();
2928
2929        assert!(!result.is_error);
2930        assert_eq!(result.details["title"], "Archived unit");
2931        assert_eq!(result.details["is_archived"], true);
2932    }
2933
2934    #[tokio::test]
2935    async fn tree_reports_archived_root_as_active_only_limitation() {
2936        let dir = tempfile::tempdir().unwrap();
2937        let mana_dir = dir.path().join(".mana");
2938        std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
2939        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2940        std::fs::write(
2941            mana_dir.join("archive/2026/04/1-archived-unit.md"),
2942            "---\nid: '1'\ntitle: Archived unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\nis_archived: true\n---\n\nbody\n",
2943        )
2944        .unwrap();
2945        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2946        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2947        let ctx = ToolContext {
2948            cwd: dir.path().to_path_buf(),
2949            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2950            update_tx: tx,
2951            command_tx: cmd_tx,
2952            ui: Arc::new(NullInterface),
2953            file_cache: Arc::new(FileCache::new()),
2954            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2955            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2956            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2957            lua_tool_loader: None,
2958            mode: crate::config::AgentMode::Full,
2959            read_max_lines: 500,
2960            turn_mana_review: Arc::new(std::sync::Mutex::new(
2961                crate::mana_review::TurnManaReviewAccumulator::default(),
2962            )),
2963            config: Arc::new(crate::config::Config::default()),
2964        };
2965        let tool = ManaTool::default();
2966        let result = tool
2967            .execute(
2968                "call_tree_archived",
2969                json!({ "action": "tree", "id": "1" }),
2970                ctx,
2971            )
2972            .await
2973            .unwrap();
2974
2975        assert!(result.is_error);
2976        let text = result.text_content().unwrap_or("");
2977        assert!(text.contains("Archived unit 1 can be shown but not rendered in tree view"));
2978    }
2979
2980    #[tokio::test]
2981    async fn root_scope_targets_outermost_mana() {
2982        let tower = tempfile::tempdir().unwrap();
2983        let root_mana = tower.path().join(".mana");
2984        std::fs::create_dir_all(&root_mana).unwrap();
2985        std::fs::write(root_mana.join("config.yaml"), "project: root\nnext_id: 2\n").unwrap();
2986        std::fs::write(
2987            root_mana.join("1-root-unit.md"),
2988            "---\nid: '1'\ntitle: Root unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2989        ).unwrap();
2990        let project = tower.path().join("imp");
2991        let project_mana = project.join(".mana");
2992        std::fs::create_dir_all(&project_mana).unwrap();
2993        std::fs::write(
2994            project_mana.join("config.yaml"),
2995            "project: nested\nnext_id: 2\n",
2996        )
2997        .unwrap();
2998        std::fs::write(
2999            project_mana.join("1-project-unit.md"),
3000            "---\nid: '1'\ntitle: Project unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3001        ).unwrap();
3002        let workdir = project.join("src");
3003        std::fs::create_dir_all(&workdir).unwrap();
3004        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3005        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3006        let ctx = ToolContext {
3007            cwd: workdir,
3008            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3009            update_tx: tx,
3010            command_tx: cmd_tx,
3011            ui: Arc::new(NullInterface),
3012            file_cache: Arc::new(FileCache::new()),
3013            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3014            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3015            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3016            lua_tool_loader: None,
3017            mode: crate::config::AgentMode::Full,
3018            read_max_lines: 500,
3019            turn_mana_review: Arc::new(std::sync::Mutex::new(
3020                crate::mana_review::TurnManaReviewAccumulator::default(),
3021            )),
3022            config: Arc::new(crate::config::Config::default()),
3023        };
3024        let tool = ManaTool::default();
3025        let result = tool
3026            .execute(
3027                "call_root_scope",
3028                json!({ "action": "show", "id": "1", "scope": "root" }),
3029                ctx,
3030            )
3031            .await
3032            .unwrap();
3033        assert_eq!(result.details["title"], "Root unit");
3034    }
3035
3036    #[tokio::test]
3037    async fn explicit_path_targets_project_outside_cwd_ancestry() {
3038        let outside = tempfile::tempdir().unwrap();
3039        let target_project = outside.path().join("other-project");
3040        let target_mana = target_project.join(".mana");
3041        std::fs::create_dir_all(&target_mana).unwrap();
3042        std::fs::write(
3043            target_mana.join("config.yaml"),
3044            "project: other\nnext_id: 2\n",
3045        )
3046        .unwrap();
3047        std::fs::write(
3048            target_mana.join("1-other-unit.md"),
3049            "---\nid: '1'\ntitle: Other unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3050        )
3051        .unwrap();
3052
3053        let unrelated = tempfile::tempdir().unwrap();
3054        let workdir = unrelated.path().join("scratch");
3055        std::fs::create_dir_all(&workdir).unwrap();
3056        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3057        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3058        let ctx = ToolContext {
3059            cwd: workdir,
3060            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3061            update_tx: tx,
3062            command_tx: cmd_tx,
3063            ui: Arc::new(NullInterface),
3064            file_cache: Arc::new(FileCache::new()),
3065            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3066            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3067            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3068            lua_tool_loader: None,
3069            mode: crate::config::AgentMode::Full,
3070            read_max_lines: 500,
3071            turn_mana_review: Arc::new(std::sync::Mutex::new(
3072                crate::mana_review::TurnManaReviewAccumulator::default(),
3073            )),
3074            config: Arc::new(crate::config::Config::default()),
3075        };
3076        let tool = ManaTool::default();
3077        let result = tool
3078            .execute(
3079                "call_explicit_path",
3080                json!({ "action": "show", "id": "1", "path": target_project }),
3081                ctx,
3082            )
3083            .await
3084            .unwrap();
3085        assert_eq!(result.details["title"], "Other unit");
3086    }
3087
3088    #[tokio::test]
3089    async fn worker_blocks_fact_create() {
3090        match run_with_mode("worker", "fact_create").await {
3091            ManaResult::ModeBlocked(_) => {}
3092            ManaResult::Attempted(out) => {
3093                panic!(
3094                    "worker should block 'fact_create', got: {:?}",
3095                    out.text_content()
3096                )
3097            }
3098        }
3099    }
3100
3101    #[tokio::test]
3102    async fn worker_allows_verify() {
3103        match run_with_mode("worker", "verify").await {
3104            ManaResult::Attempted(_) => {}
3105            ManaResult::ModeBlocked(msg) => {
3106                panic!("worker should allow 'verify' but was blocked: {msg}")
3107            }
3108        }
3109    }
3110
3111    #[tokio::test]
3112    async fn auditor_allows_show() {
3113        match run_with_mode("auditor", "show").await {
3114            ManaResult::Attempted(_) => {}
3115            ManaResult::ModeBlocked(msg) => {
3116                panic!("auditor should allow 'show' but was blocked: {msg}")
3117            }
3118        }
3119    }
3120
3121    #[tokio::test]
3122    async fn auditor_blocks_update() {
3123        match run_with_mode("auditor", "update").await {
3124            ManaResult::ModeBlocked(_) => {}
3125            ManaResult::Attempted(out) => {
3126                panic!(
3127                    "auditor should block 'update', got: {:?}",
3128                    out.text_content()
3129                )
3130            }
3131        }
3132    }
3133
3134    #[tokio::test]
3135    async fn worker_allows_logs() {
3136        match run_with_mode("worker", "logs").await {
3137            ManaResult::Attempted(_) => {}
3138            ManaResult::ModeBlocked(msg) => {
3139                panic!("worker should allow 'logs' but was blocked: {msg}")
3140            }
3141        }
3142    }
3143
3144    #[tokio::test]
3145    async fn orchestrator_allows_extended_actions() {
3146        for action in &[
3147            "status",
3148            "list",
3149            "show",
3150            "create",
3151            "close",
3152            "update",
3153            "run",
3154            "run_state",
3155            "evaluate",
3156            "claim",
3157            "release",
3158            "logs",
3159            "agents",
3160            "next",
3161        ] {
3162            match run_with_mode("orchestrator", action).await {
3163                ManaResult::Attempted(_) => {}
3164                ManaResult::ModeBlocked(msg) => {
3165                    panic!("orchestrator should allow '{action}' but was blocked: {msg}")
3166                }
3167            }
3168        }
3169    }
3170
3171    #[tokio::test]
3172    async fn ctx_mode_wins_over_env() {
3173        let prev = {
3174            let _guard = ENV_LOCK.lock().unwrap();
3175            let prev = std::env::var("IMP_MODE").ok();
3176            std::env::set_var("IMP_MODE", "full");
3177            prev
3178        };
3179
3180        let result = run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await;
3181
3182        match prev {
3183            Some(v) => {
3184                let _guard = ENV_LOCK.lock().unwrap();
3185                std::env::set_var("IMP_MODE", v)
3186            }
3187            None => {
3188                let _guard = ENV_LOCK.lock().unwrap();
3189                std::env::remove_var("IMP_MODE")
3190            }
3191        }
3192
3193        match result {
3194            ManaResult::ModeBlocked(_) => {}
3195            ManaResult::Attempted(out) => {
3196                panic!(
3197                    "ctx.mode=Worker should block 'create' even when IMP_MODE=full, got: {:?}",
3198                    out.text_content()
3199                )
3200            }
3201        }
3202    }
3203
3204    #[tokio::test]
3205    async fn ctx_worker_blocks_create() {
3206        match run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await {
3207            ManaResult::ModeBlocked(_) => {}
3208            ManaResult::Attempted(out) => {
3209                panic!(
3210                    "ctx Worker mode should block 'create', got: {:?}",
3211                    out.text_content()
3212                )
3213            }
3214        }
3215    }
3216
3217    #[tokio::test]
3218    async fn ctx_full_allows_extended_actions() {
3219        for action in &[
3220            "status",
3221            "list",
3222            "show",
3223            "create",
3224            "close",
3225            "update",
3226            "run",
3227            "run_state",
3228            "evaluate",
3229            "claim",
3230            "release",
3231            "logs",
3232            "agents",
3233            "next",
3234            "tree",
3235        ] {
3236            match run_with_ctx_mode(crate::config::AgentMode::Full, action).await {
3237                ManaResult::Attempted(_) => {}
3238                ManaResult::ModeBlocked(msg) => {
3239                    panic!("ctx Full mode should allow '{action}' but was blocked: {msg}")
3240                }
3241            }
3242        }
3243    }
3244
3245    #[tokio::test]
3246    async fn next_returns_ranked_text() {
3247        let dir = tempfile::tempdir().unwrap();
3248        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3249        let tool = ManaTool::default();
3250        let result = tool
3251            .execute("call_next", json!({ "action": "next", "count": 1 }), ctx)
3252            .await
3253            .unwrap();
3254        let text = result.text_content().unwrap_or("");
3255        assert!(text.contains("Test unit") || text.contains("No ready units"));
3256    }
3257
3258    #[tokio::test]
3259    async fn background_run_returns_promptly() {
3260        let dir = tempfile::tempdir().unwrap();
3261        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3262        let tool = ManaTool::default();
3263        let result = tool
3264            .execute(
3265                "call_bg",
3266                json!({ "action": "run", "background": true, "dry_run": true }),
3267                ctx,
3268            )
3269            .await
3270            .unwrap();
3271        let text = result.text_content().unwrap_or("");
3272        assert!(text.contains("Started native mana orchestration in background"));
3273        assert_eq!(result.details["background"], true);
3274        assert!(result.details["run_id"].as_str().is_some());
3275    }
3276
3277    #[tokio::test]
3278    async fn background_run_enqueues_follow_up_on_completion_without_ui() {
3279        let dir = tempfile::tempdir().unwrap();
3280        let mana_dir = dir.path().join(".mana");
3281        std::fs::create_dir_all(&mana_dir).unwrap();
3282        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3283        std::fs::write(
3284            mana_dir.join("1-test-unit.md"),
3285            "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3286        )
3287        .unwrap();
3288
3289        let (tx, _rx) = mpsc::channel::<ToolUpdate>(8);
3290        let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
3291        let ctx = ToolContext {
3292            cwd: dir.path().to_path_buf(),
3293            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3294            update_tx: tx,
3295            command_tx: cmd_tx,
3296            ui: Arc::new(NullInterface),
3297            file_cache: Arc::new(FileCache::new()),
3298            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3299            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3300            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3301            lua_tool_loader: None,
3302            mode: crate::config::AgentMode::Full,
3303            read_max_lines: 500,
3304            turn_mana_review: Arc::new(std::sync::Mutex::new(
3305                crate::mana_review::TurnManaReviewAccumulator::default(),
3306            )),
3307            config: Arc::new(crate::config::Config::default()),
3308        };
3309
3310        let tool = ManaTool::default();
3311        let _ = tool
3312            .execute(
3313                "call_bg_follow_up",
3314                json!({ "action": "run", "background": true, "dry_run": true }),
3315                ctx,
3316            )
3317            .await
3318            .unwrap();
3319
3320        let follow_up = tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv())
3321            .await
3322            .expect("follow-up timeout")
3323            .expect("follow-up message");
3324
3325        match follow_up {
3326            crate::agent::AgentCommand::FollowUp(text) => {
3327                assert!(
3328                    text.contains("Native mana orchestration finished"),
3329                    "text was: {text}"
3330                );
3331                assert!(
3332                    text.contains("Inspect with mana(action=\"run_state\")"),
3333                    "text was: {text}"
3334                );
3335            }
3336            other => panic!("expected follow-up, got {other:?}"),
3337        }
3338    }
3339
3340    #[tokio::test]
3341    async fn background_run_with_ui_does_not_enqueue_follow_up_on_completion() {
3342        let dir = tempfile::tempdir().unwrap();
3343        let (ctx, _keep, _widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
3344        let tool = ManaTool::default();
3345        let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
3346        let ctx = ToolContext {
3347            command_tx: cmd_tx,
3348            ..ctx
3349        };
3350
3351        let _ = tool
3352            .execute(
3353                "call_bg_follow_up_ui",
3354                json!({ "action": "run", "background": true, "dry_run": true }),
3355                ctx,
3356            )
3357            .await
3358            .unwrap();
3359
3360        let follow_up =
3361            tokio::time::timeout(std::time::Duration::from_millis(700), cmd_rx.recv()).await;
3362        match follow_up {
3363            Err(_) | Ok(None) => {}
3364            Ok(Some(msg)) => panic!(
3365                "UI mode should rely on widget/status instead of queueing duplicate follow-up chat text, got: {msg:?}"
3366            ),
3367        }
3368    }
3369
3370    #[tokio::test]
3371    async fn background_run_supports_explicit_targets() {
3372        let dir = tempfile::tempdir().unwrap();
3373        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3374        let tool = ManaTool::default();
3375        let result = tool
3376            .execute(
3377                "call_bg_targets",
3378                json!({ "action": "run", "background": true, "dry_run": true, "targets": ["1", "2"] }),
3379                ctx,
3380            )
3381            .await
3382            .unwrap();
3383        assert_eq!(result.details["target"]["kind"], "explicit");
3384        assert_eq!(result.details["target"]["ids"][0], "1");
3385        assert_eq!(result.details["target"]["ids"][1], "2");
3386    }
3387
3388    #[tokio::test]
3389    async fn run_state_and_evaluate_report_native_run() {
3390        let dir = tempfile::tempdir().unwrap();
3391        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3392        let tool = ManaTool::default();
3393
3394        let run_result = tool
3395            .execute(
3396                "call_run",
3397                json!({ "action": "run", "background": false, "dry_run": true }),
3398                ctx,
3399            )
3400            .await
3401            .unwrap();
3402        let run_id = run_result.details["run_id"]
3403            .as_str()
3404            .expect("run_id")
3405            .to_string();
3406
3407        let dir2 = tempfile::tempdir().unwrap();
3408        let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
3409        let state = tool
3410            .execute(
3411                "call_state",
3412                json!({ "action": "run_state", "run_id": run_id.as_str() }),
3413                ctx2,
3414            )
3415            .await
3416            .unwrap();
3417        let state_text = state.text_content().unwrap_or("");
3418        assert!(
3419            state_text.contains("Native mana orchestration "),
3420            "state_text was: {state_text}"
3421        );
3422        assert!(
3423            state_text.contains("Worker runtime:"),
3424            "state_text was: {state_text}"
3425        );
3426        assert!(
3427            state_text.contains("Units:") || state_text.contains("Latest: Dry run:"),
3428            "state_text was: {state_text}"
3429        );
3430        assert!(
3431            state_text.contains("all ready units") || state_text.contains("unit"),
3432            "state_text was: {state_text}"
3433        );
3434
3435        let dir3 = tempfile::tempdir().unwrap();
3436        let (ctx3, _keep3) = ctx_with_mode(dir3.path(), crate::config::AgentMode::Full);
3437        let evaluation = tool
3438            .execute(
3439                "call_eval",
3440                json!({ "action": "evaluate", "run_id": run_result.details["run_id"] }),
3441                ctx3,
3442            )
3443            .await
3444            .unwrap();
3445        let eval_text = evaluation.text_content().unwrap_or("");
3446        assert!(
3447            eval_text.contains("Native mana orchestration run ") && eval_text.contains("finished"),
3448            "eval_text was: {eval_text}"
3449        );
3450        assert!(
3451            eval_text.contains("Worker runtime:"),
3452            "eval_text was: {eval_text}"
3453        );
3454    }
3455
3456    #[test]
3457    fn run_store_prefers_active_run_snapshot() {
3458        let mut store = ManaRunStore::default();
3459        let active_id = store.start_run(
3460            "all ready units".to_string(),
3461            true,
3462            &mana::commands::run::NativeRunParams {
3463                target: mana::commands::run::RunTarget::AllReady,
3464                jobs: 2,
3465                dry_run: false,
3466                loop_mode: false,
3467                keep_going: false,
3468                timeout: 30,
3469                idle_timeout: 5,
3470                json_stream: true,
3471                review: false,
3472            },
3473        );
3474        let finished_id = store.start_run(
3475            "unit 1".to_string(),
3476            false,
3477            &mana::commands::run::NativeRunParams {
3478                target: mana::commands::run::RunTarget::Unit("1".to_string()),
3479                jobs: 1,
3480                dry_run: true,
3481                loop_mode: false,
3482                keep_going: false,
3483                timeout: 30,
3484                idle_timeout: 5,
3485                json_stream: true,
3486                review: false,
3487            },
3488        );
3489        store.fail_run(&finished_id, "done".to_string());
3490
3491        let latest = store.snapshot(None).expect("snapshot");
3492        assert_eq!(latest.run_id, active_id);
3493        assert_eq!(latest.status, "starting");
3494    }
3495
3496    #[test]
3497    fn stream_event_line_formats_tool_activity() {
3498        let line = stream_event_line(&mana::stream::StreamEvent::UnitTool {
3499            id: "1".to_string(),
3500            tool_name: "read".to_string(),
3501            tool_count: 3,
3502            file_path: Some("src/lib.rs".to_string()),
3503        })
3504        .expect("line");
3505        assert!(line.contains("#3 read"));
3506        assert!(line.contains("src/lib.rs"));
3507    }
3508
3509    #[test]
3510    fn evaluate_output_reports_failures() {
3511        let mut state = NativeRunState::new(
3512            "run-7".to_string(),
3513            "unit 7".to_string(),
3514            false,
3515            &mana::commands::run::NativeRunParams {
3516                target: mana::commands::run::RunTarget::Unit("7".to_string()),
3517                jobs: 1,
3518                dry_run: false,
3519                loop_mode: false,
3520                keep_going: false,
3521                timeout: 30,
3522                idle_timeout: 5,
3523                json_stream: true,
3524                review: false,
3525            },
3526        );
3527        state.status = "finished".to_string();
3528        state.summary.total_failed = 2;
3529        state.log_lines.push("✗ 7 failed verify".to_string());
3530
3531        let output = evaluate_run_output(&state);
3532        let text = output.text_content().unwrap_or("");
3533        assert!(text.contains("2 failed unit"));
3534        assert!(text.contains("Latest: ✗ 7 failed verify"));
3535    }
3536}