Skip to main content

imp_core/tools/
mana.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use async_trait::async_trait;
6use mana::commands::agents::{agents_file_path, load_agents};
7use mana::commands::logs::find_all_logs;
8use mana::commands::next::ScoredUnit;
9use mana::commands::run::{NativeRunParams, RunSummary, RunTarget, RunUnitStatus, RunView};
10use mana::stream::{self, StreamEvent};
11use mana_core::ops::claim::ClaimParams;
12use mana_core::ops::run as mana_run_core;
13use mana_core::unit::{OnFailAction, UnitType};
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16
17use super::{truncate_head, Tool, ToolContext, ToolOutput};
18use crate::error::Result;
19use crate::mana_worker::{self, WorkerRunOptions, WorkerStatus};
20use crate::ui::{NotifyLevel, WidgetContent};
21const MAX_OUTPUT_LINES: usize = 2000;
22const MAX_OUTPUT_BYTES: usize = 50 * 1024;
23const DEFAULT_LIST_LIMIT: usize = 20;
24const MAX_LIST_LIMIT: usize = 50;
25const MAX_STORED_RUN_EVENTS: usize = 64;
26const MAX_PERSISTED_RUN_LOG_LINES: usize = 50;
27const FINISHED_RUN_TTL_MS: u128 = 60 * 60 * 1000;
28const INTERRUPTED_RUN_STALE_MS: u128 = 6 * 60 * 60 * 1000;
29
30fn find_mana_dir(cwd: &Path) -> std::result::Result<std::path::PathBuf, String> {
31    mana_core::discovery::find_mana_dir(cwd).map_err(|e| e.to_string())
32}
33
34fn resolve_mana_dir(
35    cwd: &Path,
36    params: &serde_json::Value,
37) -> std::result::Result<std::path::PathBuf, String> {
38    // Transitional compatibility: runtime still accepts legacy alias fields even though
39    // the model-facing schema advertises only canonical `scope` and `path`.
40    let scope = params
41        .get("scope")
42        .and_then(|v| v.as_str())
43        .or_else(|| params.get("mana_scope").and_then(|v| v.as_str()))
44        .unwrap_or("auto");
45
46    if let Some(explicit) = params
47        .get("path")
48        .and_then(|v| v.as_str())
49        .or_else(|| params.get("mana_dir").and_then(|v| v.as_str()))
50    {
51        let path = Path::new(explicit);
52        let resolved = if path.is_absolute() {
53            path.to_path_buf()
54        } else {
55            cwd.join(path)
56        };
57        return Ok(
58            if resolved.file_name().and_then(|name| name.to_str()) == Some(".mana") {
59                resolved
60            } else {
61                resolved.join(".mana")
62            },
63        );
64    }
65
66    match scope {
67        "auto" | "project" => find_mana_dir(cwd),
68        "root" => mana_core::discovery::find_outermost_mana_dir(cwd).map_err(|e| e.to_string()),
69        other => Err(format!(
70            "Unknown mana scope '{other}'. Use auto, project, or root."
71        )),
72    }
73}
74
75fn normalize_runtime_value(mut runtime: serde_json::Value) -> serde_json::Value {
76    if runtime
77        .get("direct_agent")
78        .and_then(serde_json::Value::as_str)
79        == Some("pi")
80    {
81        if let Some(map) = runtime.as_object_mut() {
82            map.insert("direct_agent".to_string(), json!("imp"));
83        }
84    }
85    runtime
86}
87
88fn json_output(value: &impl serde::Serialize) -> ToolOutput {
89    match serde_json::to_string_pretty(value) {
90        Ok(json) => ToolOutput {
91            content: vec![imp_llm::ContentBlock::Text { text: json }],
92            details: serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
93            is_error: false,
94        },
95        Err(e) => ToolOutput::error(format!("Failed to serialize: {e}")),
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100struct NativeRunParamsView {
101    target: serde_json::Value,
102    jobs: u32,
103    dry_run: bool,
104    loop_mode: bool,
105    keep_going: bool,
106    timeout: u32,
107    idle_timeout: u32,
108    review: bool,
109}
110
111impl From<&NativeRunParams> for NativeRunParamsView {
112    fn from(args: &NativeRunParams) -> Self {
113        let target = match &args.target {
114            RunTarget::AllReady => json!({"kind": "all_ready"}),
115            RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
116            RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
117        };
118        Self {
119            target,
120            jobs: args.jobs,
121            dry_run: args.dry_run,
122            loop_mode: args.loop_mode,
123            keep_going: args.keep_going,
124            timeout: args.timeout,
125            idle_timeout: args.idle_timeout,
126            review: args.review,
127        }
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132struct NativeRunState {
133    run_id: String,
134    scope: String,
135    status: String,
136    error: Option<String>,
137    started_at_ms: u128,
138    finished_at_ms: Option<u128>,
139    #[serde(default)]
140    last_event_at_ms: u128,
141    args: NativeRunParamsView,
142    runtime: Option<serde_json::Value>,
143    summary: RunSummary,
144    units: Vec<RunUnitStatus>,
145    log_lines: Vec<String>,
146    event_count: usize,
147}
148
149impl NativeRunState {
150    fn new(run_id: String, scope: String, args: &NativeRunParams) -> Self {
151        Self {
152            run_id,
153            scope,
154            status: "starting".to_string(),
155            error: None,
156            started_at_ms: unix_time_ms(),
157            finished_at_ms: None,
158            last_event_at_ms: unix_time_ms(),
159            args: NativeRunParamsView::from(args),
160            runtime: None,
161            summary: RunSummary {
162                total_units: 0,
163                total_rounds: 0,
164                total_closed: 0,
165                total_failed: 0,
166                total_abandoned: 0,
167                total_awaiting_verify: 0,
168                total_skipped: 0,
169                duration_secs: 0,
170            },
171            units: Vec::new(),
172            log_lines: Vec::new(),
173            event_count: 0,
174        }
175    }
176
177    fn apply_event(&mut self, event: &StreamEvent) {
178        self.event_count += 1;
179        self.last_event_at_ms = unix_time_ms();
180        if let Some(line) = stream_event_line(event) {
181            self.log_lines.push(line);
182            trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
183        }
184
185        match event {
186            StreamEvent::RunStart {
187                total_units,
188                total_rounds,
189                units,
190                runtime,
191                ..
192            } => {
193                self.status = "running".to_string();
194                self.summary.total_units = *total_units;
195                self.summary.total_rounds = *total_rounds;
196                self.runtime = runtime
197                    .as_ref()
198                    .and_then(|value| serde_json::to_value(value).ok())
199                    .map(normalize_runtime_value);
200                self.units = units
201                    .iter()
202                    .map(|info| RunUnitStatus {
203                        id: info.id.clone(),
204                        title: info.title.clone(),
205                        status: "queued".to_string(),
206                        round: Some(info.round),
207                        agent: None,
208                        model: None,
209                        duration_secs: None,
210                        tool_count: None,
211                        turns: None,
212                        failure_summary: None,
213                        error: None,
214                    })
215                    .collect();
216                self.units.sort_by(|a, b| a.id.cmp(&b.id));
217            }
218            StreamEvent::RunPlan {
219                total_units,
220                runtime,
221                ..
222            } => {
223                self.status = "running".to_string();
224                self.summary.total_units = (*total_units).max(self.summary.total_units);
225                if runtime.is_some() {
226                    self.runtime = runtime
227                        .as_ref()
228                        .and_then(|value| serde_json::to_value(value).ok())
229                        .map(normalize_runtime_value);
230                }
231            }
232            StreamEvent::RoundStart { total_rounds, .. } => {
233                self.status = "running".to_string();
234                self.summary.total_rounds = (*total_rounds).max(self.summary.total_rounds);
235            }
236            StreamEvent::UnitReady { id, title, .. } => {
237                let unit = ensure_unit_status(&mut self.units, id, title);
238                unit.status = "queued".to_string();
239            }
240            StreamEvent::UnitStart {
241                id, title, round, ..
242            } => {
243                self.status = "running".to_string();
244                let unit = ensure_unit_status(&mut self.units, id, title);
245                unit.title = title.clone();
246                unit.round = Some(*round);
247                unit.status = "running".to_string();
248            }
249            StreamEvent::UnitDone {
250                id,
251                success,
252                duration_secs,
253                error,
254                tool_count,
255                turns,
256                failure_summary,
257                ..
258            } => {
259                let unit = ensure_unit_status(&mut self.units, id, id);
260                unit.status = if *success { "done" } else { "failed" }.to_string();
261                unit.duration_secs = Some(*duration_secs);
262                unit.tool_count = *tool_count;
263                unit.turns = *turns;
264                unit.failure_summary = failure_summary.clone();
265                unit.error = error.clone();
266            }
267            StreamEvent::BatchVerify { passed, failed, .. } => {
268                for id in passed {
269                    let unit = ensure_unit_status(&mut self.units, id, id);
270                    unit.status = "done".to_string();
271                }
272                for id in failed {
273                    let unit = ensure_unit_status(&mut self.units, id, id);
274                    unit.status = "failed".to_string();
275                }
276            }
277            StreamEvent::RunEnd {
278                total_closed,
279                total_failed,
280                total_abandoned,
281                total_awaiting_verify,
282                total_skipped,
283                duration_secs,
284                ..
285            } => {
286                self.summary.total_closed = *total_closed;
287                self.summary.total_failed = *total_failed;
288                self.summary.total_abandoned = *total_abandoned;
289                self.summary.total_awaiting_verify = *total_awaiting_verify;
290                self.summary.total_skipped = *total_skipped;
291                self.summary.duration_secs = *duration_secs;
292                self.status = "finished".to_string();
293                self.finished_at_ms = Some(unix_time_ms());
294            }
295            StreamEvent::DryRun { runtime, .. } => {
296                self.status = "finished".to_string();
297                if runtime.is_some() {
298                    self.runtime = runtime
299                        .as_ref()
300                        .and_then(|value| serde_json::to_value(value).ok());
301                }
302                self.finished_at_ms = Some(unix_time_ms());
303            }
304            StreamEvent::Error { message } => {
305                self.status = "failed".to_string();
306                self.error = Some(message.clone());
307                self.finished_at_ms = Some(unix_time_ms());
308            }
309            _ => {}
310        }
311    }
312
313    fn finish_with_view(&mut self, view: &RunView) {
314        let now = unix_time_ms();
315        self.summary = view.summary.clone();
316        self.units = view.units.clone();
317        self.runtime = view
318            .runtime
319            .as_ref()
320            .and_then(|value| serde_json::to_value(value).ok())
321            .map(normalize_runtime_value);
322        self.status = "finished".to_string();
323        self.error = None;
324        self.finished_at_ms = Some(now);
325        self.last_event_at_ms = now;
326    }
327
328    fn fail(&mut self, error: String) {
329        let now = unix_time_ms();
330        self.status = "failed".to_string();
331        self.error = Some(error.clone());
332        self.finished_at_ms = Some(now);
333        self.last_event_at_ms = now;
334        self.log_lines.push(error);
335        trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
336    }
337
338    fn persisted(&self) -> Self {
339        let mut state = self.clone();
340        trim_log_lines(&mut state.log_lines, MAX_PERSISTED_RUN_LOG_LINES);
341        state
342    }
343}
344
345#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346struct ManaRunStore {
347    next_id: u64,
348    runs: Vec<NativeRunState>,
349}
350
351impl ManaRunStore {
352    fn start_run(&mut self, scope: String, args: &NativeRunParams) -> String {
353        self.next_id += 1;
354        let run_id = format!("run-{}", self.next_id);
355        self.runs
356            .push(NativeRunState::new(run_id.clone(), scope, args));
357        self.trim_history();
358        run_id
359    }
360
361    fn persist(&self) {
362        let path = run_state_file();
363        let persisted = Self {
364            next_id: self.next_id,
365            runs: self.runs.iter().map(NativeRunState::persisted).collect(),
366        };
367        if let Ok(json) = serde_json::to_string_pretty(&persisted) {
368            let _ = std::fs::write(path, json);
369        }
370    }
371
372    fn load_persisted() -> Self {
373        let path = run_state_file();
374        if !path.exists() {
375            return Self::default();
376        }
377
378        let Ok(contents) = std::fs::read_to_string(path) else {
379            return Self::default();
380        };
381        if contents.trim().is_empty() {
382            return Self::default();
383        }
384
385        let Ok(mut store) = serde_json::from_str::<Self>(&contents) else {
386            return Self::default();
387        };
388
389        store.discard_expired_finished_runs();
390        store.classify_stale_unfinished_runs();
391        store.trim_history();
392        store
393    }
394
395    fn discard_expired_finished_runs(&mut self) {
396        let cutoff = unix_time_ms().saturating_sub(FINISHED_RUN_TTL_MS);
397        self.runs.retain(|run| match run.finished_at_ms {
398            Some(finished_at_ms) => finished_at_ms >= cutoff,
399            None => true,
400        });
401    }
402
403    fn classify_stale_unfinished_runs(&mut self) {
404        let cutoff = unix_time_ms().saturating_sub(INTERRUPTED_RUN_STALE_MS);
405        for run in &mut self.runs {
406            if (run.status == "starting" || run.status == "running")
407                && run.finished_at_ms.is_none()
408                && run.last_event_at_ms > 0
409                && run.last_event_at_ms < cutoff
410            {
411                run.status = "interrupted".to_string();
412                run.error = Some(
413                    "Run state is stale after process restart or lost background worker; inspect logs before rerun".to_string(),
414                );
415                run.finished_at_ms = Some(run.last_event_at_ms);
416                run.log_lines.push(
417                    "Run marked interrupted: stale persisted running state after reload"
418                        .to_string(),
419                );
420                trim_log_lines(&mut run.log_lines, MAX_STORED_RUN_EVENTS);
421            }
422        }
423    }
424
425    fn update_with_event(&mut self, run_id: &str, event: &StreamEvent) {
426        if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
427            run.apply_event(event);
428        }
429    }
430
431    fn finish_run(&mut self, run_id: &str, view: &RunView) {
432        if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
433            run.finish_with_view(view);
434        }
435        self.trim_history();
436    }
437
438    fn fail_run(&mut self, run_id: &str, error: String) {
439        if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
440            run.fail(error);
441        }
442        self.trim_history();
443    }
444
445    fn snapshot(&self, run_id: Option<&str>) -> Option<NativeRunState> {
446        if let Some(run_id) = run_id {
447            return self.runs.iter().find(|run| run.run_id == run_id).cloned();
448        }
449
450        self.runs
451            .iter()
452            .rev()
453            .find(|run| run.status == "starting" || run.status == "running")
454            .cloned()
455            .or_else(|| self.runs.last().cloned())
456    }
457
458    fn trim_history(&mut self) {
459        while self.runs.len() > 8 {
460            let newest_index = self.runs.len().saturating_sub(1);
461            if let Some(index) =
462                self.runs
463                    .iter()
464                    .enumerate()
465                    .take(newest_index)
466                    .find_map(|(index, run)| {
467                        (run.status != "starting" && run.status != "running").then_some(index)
468                    })
469            {
470                self.runs.remove(index);
471            } else if !self.runs.is_empty() {
472                self.runs.remove(0);
473            } else {
474                break;
475            }
476        }
477    }
478}
479
480fn trim_log_lines(log_lines: &mut Vec<String>, max_lines: usize) {
481    if log_lines.len() > max_lines {
482        let overflow = log_lines.len() - max_lines;
483        log_lines.drain(0..overflow);
484    }
485}
486
487fn run_state_file() -> std::path::PathBuf {
488    if let Ok(path) = agents_file_path() {
489        if let Some(dir) = path.parent() {
490            std::fs::create_dir_all(dir).ok();
491            return dir.join("run_state.json");
492        }
493    }
494
495    let dir = std::env::var("HOME")
496        .map(|h| {
497            std::path::PathBuf::from(h)
498                .join(".local")
499                .join("share")
500                .join("units")
501        })
502        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp").join("mana"));
503    std::fs::create_dir_all(&dir).ok();
504    dir.join("run_state.json")
505}
506
507fn unix_time_ms() -> u128 {
508    std::time::SystemTime::now()
509        .duration_since(std::time::UNIX_EPOCH)
510        .unwrap_or_default()
511        .as_millis()
512}
513
514fn ensure_unit_status<'a>(
515    units: &'a mut Vec<RunUnitStatus>,
516    id: &str,
517    title: &str,
518) -> &'a mut RunUnitStatus {
519    if let Some(index) = units.iter().position(|unit| unit.id == id) {
520        return &mut units[index];
521    }
522
523    units.push(RunUnitStatus {
524        id: id.to_string(),
525        title: title.to_string(),
526        status: "queued".to_string(),
527        round: None,
528        agent: None,
529        model: None,
530        duration_secs: None,
531        tool_count: None,
532        turns: None,
533        failure_summary: None,
534        error: None,
535    });
536    let index = units.len() - 1;
537    &mut units[index]
538}
539
540fn stream_event_line(event: &StreamEvent) -> Option<String> {
541    match event {
542        StreamEvent::RunStart {
543            total_units,
544            total_rounds,
545            ..
546        } => Some(format!(
547            "Mana run started: {total_units} jobs across {total_rounds} waves"
548        )),
549        StreamEvent::RunPlan {
550            waves,
551            file_overlaps,
552            ..
553        } => Some(format!(
554            "Plan ready: {} waves · {} overlapping file groups",
555            waves.len(),
556            file_overlaps.len()
557        )),
558        StreamEvent::RoundStart {
559            round,
560            total_rounds,
561            unit_count,
562        } => Some(format!(
563            "Round {round}/{total_rounds}: {unit_count} unit(s)"
564        )),
565        StreamEvent::UnitReady {
566            id,
567            title,
568            unblocked_by,
569        } => Some(format!("Ready: {id} {title} (unblocked by {unblocked_by})")),
570        StreamEvent::UnitStart {
571            id, title, round, ..
572        } => Some(format!("▶ {id}  {title}  wave {round}")),
573        StreamEvent::UnitThinking { id, text } => {
574            Some(format!("… {id}  {}", truncate_line_for_log(text)))
575        }
576        StreamEvent::UnitTool {
577            id,
578            tool_name,
579            tool_count,
580            file_path,
581        } => Some(match file_path {
582            Some(path) => format!("⚙ {id}  #{tool_count} {tool_name}  {path}"),
583            None => format!("⚙ {id}  #{tool_count} {tool_name}"),
584        }),
585        StreamEvent::UnitTokens {
586            id,
587            input_tokens,
588            output_tokens,
589            cost,
590            ..
591        } => Some(format!(
592            "$ {id}  in {input_tokens} · out {output_tokens} · ${cost:.4}"
593        )),
594        StreamEvent::UnitDone {
595            id,
596            success,
597            duration_secs,
598            error,
599            ..
600        } => Some(if *success {
601            format!("✓ {id}  done  {duration_secs}s")
602        } else {
603            format!(
604                "✗ {id}  failed  {}",
605                error.clone().unwrap_or_else(|| "error".to_string())
606            )
607        }),
608        StreamEvent::RoundEnd {
609            round,
610            success_count,
611            failed_count,
612        } => Some(format!(
613            "Round {round} complete: {success_count} done · {failed_count} failed"
614        )),
615        StreamEvent::RunEnd {
616            total_closed,
617            total_failed,
618            duration_secs,
619            ..
620        } => Some(format!(
621            "Mana run finished: {total_closed} done · {total_failed} failed · {duration_secs}s"
622        )),
623        StreamEvent::BatchVerify {
624            commands_run,
625            passed,
626            failed,
627        } => Some(format!(
628            "Batch verify: {commands_run} command(s) · {} passed · {} failed",
629            passed.len(),
630            failed.len()
631        )),
632        StreamEvent::VerifyGroupRun {
633            command,
634            unit_ids,
635            success,
636        } => Some(format!(
637            "Verify command: {} · {} unit(s) · {}",
638            truncate_line_for_log(command),
639            unit_ids.len(),
640            if *success { "passed" } else { "failed" }
641        )),
642        StreamEvent::DryRun { rounds, .. } => {
643            Some(format!("Dry run: {} planned wave(s)", rounds.len()))
644        }
645        StreamEvent::Error { message } => Some(format!("Run error: {message}")),
646    }
647}
648
649fn truncate_line_for_log(text: &str) -> String {
650    const MAX_CHARS: usize = 160;
651    let mut out = String::new();
652    let mut chars = text.chars();
653    for _ in 0..MAX_CHARS {
654        if let Some(ch) = chars.next() {
655            out.push(ch);
656        } else {
657            return out;
658        }
659    }
660    if chars.next().is_some() {
661        out.push('…');
662    }
663    out
664}
665
666fn update_run_store_with_event(
667    store: &std::sync::Mutex<ManaRunStore>,
668    run_id: &str,
669    event: &StreamEvent,
670) {
671    if let Ok(mut store) = store.lock() {
672        store.update_with_event(run_id, event);
673        store.persist();
674    }
675}
676
677fn finish_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, view: &RunView) {
678    if let Ok(mut store) = store.lock() {
679        store.finish_run(run_id, view);
680        store.persist();
681    }
682}
683
684fn fail_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, error: String) {
685    if let Ok(mut store) = store.lock() {
686        store.fail_run(run_id, error);
687        store.persist();
688    }
689}
690
691fn mana_widget_lines(summary: impl Into<String>, detail: Option<String>) -> WidgetContent {
692    let mut lines = vec![summary.into()];
693    if let Some(detail) = detail {
694        lines.push(detail);
695    }
696    WidgetContent::Lines(lines)
697}
698
699fn mana_run_widget_lines(
700    run_id: &str,
701    scope: &str,
702    state: Option<&NativeRunState>,
703) -> WidgetContent {
704    let Some(state) = state else {
705        return mana_widget_lines(
706            format!("mana {run_id}: starting"),
707            Some(format!("{scope} · waiting for first event")),
708        );
709    };
710
711    let summary = format!(
712        "mana {}: {} · {}/{} done · {} failed",
713        state.run_id,
714        state.status,
715        state.summary.total_closed,
716        state.summary.total_units,
717        state.summary.total_failed
718    );
719    let mut detail = vec![format!(
720        "{} · {} events · {}s elapsed",
721        state.scope,
722        state.event_count,
723        unix_time_ms().saturating_sub(state.started_at_ms) / 1000
724    )];
725    if let Some(active) = state.units.iter().find(|unit| unit.status == "running") {
726        detail.push(format!("running {} {}", active.id, active.title));
727    } else if let Some(queued) = state.units.iter().find(|unit| unit.status == "queued") {
728        detail.push(format!("queued {} {}", queued.id, queued.title));
729    }
730    if let Some(last) = state.log_lines.last() {
731        detail.push(last.clone());
732    }
733    mana_widget_lines(summary, Some(detail.join(" · ")))
734}
735
736async fn set_mana_delta_widget(
737    ctx: &ToolContext,
738    summary: impl Into<String>,
739    detail: Option<String>,
740) {
741    ctx.ui
742        .set_widget("mana", Some(mana_widget_lines(summary, detail)))
743        .await;
744}
745
746fn unit_delta_label(unit: &serde_json::Value) -> Option<String> {
747    let id = unit.get("id").and_then(|v| v.as_str())?;
748    let title = unit
749        .get("title")
750        .and_then(|v| v.as_str())
751        .unwrap_or("(untitled)");
752    Some(format!("{id} · {title}"))
753}
754
755fn target_from_params(params: &serde_json::Value) -> Result<RunTarget> {
756    if let Some(values) = params["targets"].as_array() {
757        let ids: Vec<String> = values
758            .iter()
759            .filter_map(|value| value.as_str().map(|s| s.to_string()))
760            .collect();
761        if ids.is_empty() {
762            return Err(crate::error::Error::Tool(
763                "mana run targets must contain at least one string id".into(),
764            ));
765        }
766        return Ok(RunTarget::Explicit(ids));
767    }
768
769    if let Some(id) = params["id"].as_str() {
770        return Ok(RunTarget::Unit(id.to_string()));
771    }
772
773    Ok(RunTarget::AllReady)
774}
775
776fn target_ids_from_run_target(target: &RunTarget) -> Vec<String> {
777    match target {
778        RunTarget::Unit(id) => vec![id.clone()],
779        RunTarget::Explicit(ids) => ids.clone(),
780        RunTarget::AllReady => Vec::new(),
781    }
782}
783
784fn scope_from_target(target: &RunTarget) -> String {
785    match target {
786        RunTarget::AllReady => "all ready units".to_string(),
787        RunTarget::Unit(id) => format!("unit {id}"),
788        RunTarget::Explicit(ids) => format!("targets {}", ids.join(", ")),
789    }
790}
791
792fn make_follow_up_summary(scope: &str, view: &RunView) -> String {
793    let mut summary = if view.summary.total_failed > 0 {
794        format!(
795            "Native mana orchestration finished for {scope}: {} done, {} failed, {} candidate complete / awaiting verify.",
796            view.summary.total_closed,
797            view.summary.total_failed,
798            view.summary.total_awaiting_verify
799        )
800    } else if view.summary.total_awaiting_verify > 0 {
801        format!(
802            "Native mana orchestration finished for {scope}: {} done, {} candidate complete / awaiting verify.",
803            view.summary.total_closed, view.summary.total_awaiting_verify
804        )
805    } else {
806        format!(
807            "Native mana orchestration finished for {scope}: {} done, 0 failed.",
808            view.summary.total_closed
809        )
810    };
811
812    if let Some(runtime) = &view.runtime {
813        let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
814        let model = runtime.model.as_deref().unwrap_or("default-model");
815        summary.push_str(&format!(
816            " Orchestration ran through mana; worker runtime: {agent} · {model}."
817        ));
818    }
819
820    summary.push_str(" Inspect with mana(action=\"run_state\") or mana(action=\"evaluate\").");
821    summary
822}
823
824fn parse_csv_strings(value: &serde_json::Value, field_name: &str) -> Result<Vec<String>> {
825    if let Some(values) = value.as_array() {
826        let parsed = values
827            .iter()
828            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
829            .filter(|s| !s.is_empty())
830            .collect();
831        return Ok(parsed);
832    }
833
834    if let Some(raw) = value.as_str() {
835        return Ok(raw
836            .split(',')
837            .map(|s| s.trim().to_string())
838            .filter(|s| !s.is_empty())
839            .collect());
840    }
841
842    if value.is_null() {
843        return Ok(Vec::new());
844    }
845
846    Err(crate::error::Error::Tool(format!(
847        "{field_name} must be a comma-separated string or array of strings"
848    )))
849}
850
851fn parse_optional_string(value: &serde_json::Value) -> Option<String> {
852    value
853        .as_str()
854        .map(str::trim)
855        .filter(|s| !s.is_empty())
856        .map(|s| s.to_string())
857}
858
859fn parse_on_fail(value: &serde_json::Value) -> Result<Option<OnFailAction>> {
860    if value.is_null() {
861        return Ok(None);
862    }
863
864    if let Some(raw) = value.as_str() {
865        return mana_core::ops::create::parse_on_fail(raw)
866            .map(Some)
867            .map_err(|e| crate::error::Error::Tool(e.to_string()));
868    }
869
870    let Some(obj) = value.as_object() else {
871        return Err(crate::error::Error::Tool(
872            "on_fail must be a string like 'retry:3'/'escalate:P1' or an object".into(),
873        ));
874    };
875
876    let action = obj
877        .get("action")
878        .and_then(|v| v.as_str())
879        .ok_or_else(|| crate::error::Error::Tool("on_fail object requires 'action'".into()))?;
880
881    match action {
882        "retry" => Ok(Some(OnFailAction::Retry {
883            max: obj.get("max").and_then(|v| v.as_u64()).map(|v| v as u32),
884            delay_secs: obj.get("delay_secs").and_then(|v| v.as_u64()),
885        })),
886        "escalate" => Ok(Some(OnFailAction::Escalate {
887            priority: obj
888                .get("priority")
889                .and_then(|v| v.as_u64())
890                .map(|v| v as u8),
891            message: obj
892                .get("message")
893                .and_then(|v| v.as_str())
894                .map(|s| s.trim().to_string())
895                .filter(|s| !s.is_empty()),
896        })),
897        other => Err(crate::error::Error::Tool(format!(
898            "unsupported on_fail action: {other}"
899        ))),
900    }
901}
902
903fn parent_placement_details(
904    parent: Option<&str>,
905    parent_reason: Option<&str>,
906) -> serde_json::Value {
907    match (parent, parent_reason) {
908        (Some(parent), Some(reason)) if !reason.trim().is_empty() => json!({
909            "parent": parent,
910            "parent_reason": reason,
911            "warning": null,
912            "hint": "Parent placement was explained explicitly.",
913        }),
914        (Some(parent), _) => json!({
915            "parent": parent,
916            "parent_reason": null,
917            "warning": "parent_reason_missing",
918            "hint": "Before creating follow-up work under the active epic, confirm it belongs to that product/scope. If this is a workflow, reliability, or cross-cutting issue, attach it to a matching epic or create one instead.",
919        }),
920        (None, _) => json!({
921            "parent": null,
922            "parent_reason": null,
923            "warning": null,
924            "hint": "No parent selected. For durable multi-step work, choose or create the matching epic before adding child tasks.",
925        }),
926    }
927}
928
929fn mana_close_force_reason_error(id: &str) -> ToolOutput {
930    ToolOutput {
931        content: vec![imp_llm::ContentBlock::Text {
932            text: format!(
933                "mana close {id} with force=true requires reason with equivalent verify evidence"
934            ),
935        }],
936        details: json!({
937            "action": "close",
938            "id": id,
939            "ok": false,
940            "force": true,
941            "missing": ["reason"],
942            "hint": "When stored verify is stale or invalid, rerun equivalent checks and close with force=true plus a reason that names the passing commands/evidence.",
943            "example": {
944                "action": "close",
945                "id": id,
946                "force": true,
947                "reason": "Equivalent verify passed: cargo test -p imp-core mana -- --nocapture; commit abc123"
948            }
949        }),
950        is_error: true,
951    }
952}
953
954fn mana_close_error_output(id: &str, error: String) -> ToolOutput {
955    let verify_related = is_close_verify_error(&error);
956    let hint = if verify_related {
957        Some(
958            "If the stored verify command is stale or invalid, run equivalent focused checks, then close with force=true and reason containing the passing commands/evidence.",
959        )
960    } else {
961        None
962    };
963    let text = match hint {
964        Some(hint) => format!("{error}\n\nRecovery: {hint}"),
965        None => error.clone(),
966    };
967    ToolOutput {
968        content: vec![imp_llm::ContentBlock::Text { text }],
969        details: json!({
970            "action": "close",
971            "id": id,
972            "ok": false,
973            "error": error,
974            "verify_related": verify_related,
975            "recovery_hint": hint,
976            "force_requires_reason": true,
977        }),
978        is_error: true,
979    }
980}
981
982fn is_close_verify_error(error: &str) -> bool {
983    let lower = error.to_ascii_lowercase();
984    lower.contains("verify")
985        || lower.contains("verification")
986        || lower.contains("exit")
987        || lower.contains("command")
988        || lower.contains("timed out")
989}
990
991fn mana_validation_error(
992    action: &str,
993    missing: Vec<&'static str>,
994    invalid: Vec<&'static str>,
995    hint: &'static str,
996    canonical_fields: Vec<&'static str>,
997) -> ToolOutput {
998    ToolOutput {
999        content: vec![imp_llm::ContentBlock::Text {
1000            text: format!("mana {action} validation failed: {hint}"),
1001        }],
1002        details: json!({
1003            "action": action,
1004            "ok": false,
1005            "missing": missing,
1006            "invalid": invalid,
1007            "hint": hint,
1008            "canonical_fields": canonical_fields,
1009        }),
1010        is_error: true,
1011    }
1012}
1013
1014fn has_text(params: &serde_json::Value, field: &str) -> bool {
1015    parse_optional_string(&params[field]).is_some()
1016}
1017
1018fn has_nonempty_csv(params: &serde_json::Value, field: &str) -> bool {
1019    parse_csv_strings(&params[field], field)
1020        .map(|values| !values.is_empty())
1021        .unwrap_or(false)
1022}
1023
1024fn validate_mana_action(action: &str, params: &serde_json::Value) -> Option<ToolOutput> {
1025    let missing = |fields: Vec<&'static str>, hint: &'static str, canonical: Vec<&'static str>| {
1026        Some(mana_validation_error(
1027            action,
1028            fields,
1029            Vec::new(),
1030            hint,
1031            canonical,
1032        ))
1033    };
1034
1035    if params.get("path").is_some() && params.get("paths").is_none() {
1036        match action {
1037            "create" | "update" | "fact_create" => {
1038                return Some(mana_validation_error(
1039                    action,
1040                    Vec::new(),
1041                    vec!["path"],
1042                    "Use path for project/.mana location; use paths to attach relevant files to units.",
1043                    vec!["path", "paths"],
1044                ));
1045            }
1046            _ => {}
1047        }
1048    }
1049
1050    match action {
1051        "show" | "claim" | "release" | "close" | "reopen" | "verify" | "fail" | "delete" => {
1052            if !has_text(params, "id") {
1053                return missing(vec!["id"], "Provide the unit id.", vec!["id"]);
1054            }
1055        }
1056        "create" => {
1057            if !has_text(params, "title") {
1058                return missing(
1059                    vec!["title"],
1060                    "create requires title. Before creating, check list/show for an existing relevant unit; update or notes_append when the durable state belongs there. For executable tasks, include description, acceptance, and verify.",
1061                    vec!["title", "description", "acceptance", "verify", "paths"],
1062                );
1063            }
1064        }
1065        "update" => {
1066            if !has_text(params, "id") {
1067                return missing(
1068                    vec!["id"],
1069                    "update requires the unit id to modify.",
1070                    vec!["id"],
1071                );
1072            }
1073        }
1074        "notes_append" => {
1075            let mut fields = Vec::new();
1076            if !has_text(params, "id") {
1077                fields.push("id");
1078            }
1079            if !has_text(params, "notes") {
1080                fields.push("notes");
1081            }
1082            if !fields.is_empty() {
1083                return missing(
1084                    fields,
1085                    "notes_append requires id and notes; use notes for durable progress/context.",
1086                    vec!["id", "notes"],
1087                );
1088            }
1089        }
1090        "decision_add" => {
1091            let mut fields = Vec::new();
1092            if !has_text(params, "id") {
1093                fields.push("id");
1094            }
1095            if !has_text(params, "description") && !has_nonempty_csv(params, "decisions") {
1096                fields.push("description");
1097            }
1098            if !fields.is_empty() {
1099                return missing(
1100                    fields,
1101                    "decision_add requires id and description/decisions for scope, architecture, or sequencing choices.",
1102                    vec!["id", "description", "decisions"],
1103                );
1104            }
1105        }
1106        "decision_resolve" => {
1107            let mut fields = Vec::new();
1108            if !has_text(params, "id") {
1109                fields.push("id");
1110            }
1111            if !has_nonempty_csv(params, "resolve_decisions") {
1112                fields.push("resolve_decisions");
1113            }
1114            if !fields.is_empty() {
1115                return missing(
1116                    fields,
1117                    "decision_resolve requires id and resolve_decisions.",
1118                    vec!["id", "resolve_decisions"],
1119                );
1120            }
1121        }
1122        "dep_add" | "dep_remove" => {
1123            let mut fields = Vec::new();
1124            if !has_text(params, "from_id") {
1125                fields.push("from_id");
1126            }
1127            if !has_text(params, "dep_id") {
1128                fields.push("dep_id");
1129            }
1130            if !fields.is_empty() {
1131                return missing(
1132                    fields,
1133                    "Dependency edits require from_id and dep_id.",
1134                    vec!["from_id", "dep_id"],
1135                );
1136            }
1137        }
1138        "fact_create" => {
1139            let mut fields = Vec::new();
1140            if !has_text(params, "title") {
1141                fields.push("title");
1142            }
1143            if !has_text(params, "verify") {
1144                fields.push("verify");
1145            }
1146            if !fields.is_empty() {
1147                return missing(
1148                    fields,
1149                    "fact_create requires title and verify so the fact is re-checkable.",
1150                    vec!["title", "verify", "paths"],
1151                );
1152            }
1153        }
1154        "logs" => {
1155            if !has_text(params, "id") && !has_text(params, "run_id") {
1156                return missing(
1157                    vec!["id"],
1158                    "logs requires id or run_id.",
1159                    vec!["id", "run_id"],
1160                );
1161            }
1162        }
1163        "reparent" => {
1164            if !has_text(params, "id") {
1165                return missing(
1166                    vec!["id"],
1167                    "reparent requires id and parent.",
1168                    vec!["id", "parent", "reason"],
1169                );
1170            }
1171            if params.get("parent").is_none() {
1172                return missing(
1173                    vec!["parent"],
1174                    "reparent requires the new parent id. Root detach can be added as a separate explicit action later.",
1175                    vec!["id", "parent", "reason"],
1176                );
1177            }
1178        }
1179        "run_state" | "evaluate" => {
1180            if !has_text(params, "run_id") {
1181                return missing(
1182                    vec!["run_id"],
1183                    "run_state/evaluate requires run_id from mana action=run.",
1184                    vec!["run_id"],
1185                );
1186            }
1187        }
1188        "run" => {
1189            if params.get("target").is_some() && params.get("targets").is_none() {
1190                return Some(mana_validation_error(
1191                    action,
1192                    Vec::new(),
1193                    vec!["target"],
1194                    "Use targets for explicit unit ids; target is an internal run concept.",
1195                    vec!["targets", "id"],
1196                ));
1197            }
1198        }
1199        _ => {}
1200    }
1201    None
1202}
1203
1204fn parse_unit_kind(value: &serde_json::Value) -> Result<Option<UnitType>> {
1205    let Some(raw) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) else {
1206        return Ok(None);
1207    };
1208
1209    match raw {
1210        "epic" => Ok(Some(UnitType::Epic)),
1211        "task" => Ok(Some(UnitType::Task)),
1212        // Transitional compatibility: accept legacy `job` at runtime, but keep it out
1213        // of the model-facing schema so new calls converge on `task`.
1214        "job" => Ok(Some(UnitType::Task)),
1215        "fact" => Ok(Some(UnitType::Fact)),
1216        other => Err(crate::error::Error::Tool(format!(
1217            "kind must be one of: epic, task, fact (legacy runtime alias: job; got {other})"
1218        ))),
1219    }
1220}
1221
1222fn run_started_output(scope: &str, run_id: &str, run_args: &NativeRunParams) -> ToolOutput {
1223    let text = format!("Started native mana orchestration for {scope} as {run_id}.");
1224    ToolOutput {
1225        content: vec![imp_llm::ContentBlock::Text { text }],
1226        details: json!({
1227            "action": "run",
1228            "run_id": run_id,
1229            "scope": scope,
1230            "target": match &run_args.target {
1231                RunTarget::AllReady => json!({"kind": "all_ready"}),
1232                RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
1233                RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
1234            },
1235            "jobs": run_args.jobs,
1236            "loop": run_args.loop_mode,
1237            "dry_run": run_args.dry_run,
1238            "review": run_args.review,
1239            "timeout": run_args.timeout,
1240            "idle_timeout": run_args.idle_timeout,
1241            "status": "started",
1242            "next": {
1243                "state": format!("mana(action=\\\"run_state\\\", run_id=\\\"{run_id}\\\")"),
1244                "logs": format!("mana(action=\\\"logs\\\", run_id=\\\"{run_id}\\\")")
1245            }
1246        }),
1247        is_error: false,
1248    }
1249}
1250
1251fn runtime_info_for_run(
1252    _args: &NativeRunParams,
1253    ctx: &ToolContext,
1254) -> mana::commands::run::RunRuntimeInfo {
1255    mana::commands::run::RunRuntimeInfo {
1256        direct_agent: Some("imp".to_string()),
1257        model: ctx.config.model.clone(),
1258    }
1259}
1260
1261fn stream_runtime_info_for_run(
1262    args: &NativeRunParams,
1263    ctx: &ToolContext,
1264) -> stream::RunRuntimeInfo {
1265    runtime_info_for_run(args, ctx).into()
1266}
1267
1268fn core_target_from_native(target: &RunTarget) -> mana_core::api::RunTarget {
1269    match target {
1270        RunTarget::AllReady => mana_core::api::RunTarget::AllReady,
1271        RunTarget::Unit(id) => mana_core::api::RunTarget::Unit(id.clone()),
1272        RunTarget::Explicit(ids) => mana_core::api::RunTarget::Explicit(ids.clone()),
1273    }
1274}
1275
1276fn validate_native_run_target(target: &RunTarget) -> std::result::Result<(), String> {
1277    match target {
1278        RunTarget::Explicit(ids) if ids.len() > 1 => Err(
1279            "native mana run does not yet support multiple explicit targets; run one target at a time"
1280                .to_string(),
1281        ),
1282        _ => Ok(()),
1283    }
1284}
1285
1286fn mana_workspace_root(mana_dir: &Path, fallback: &Path) -> PathBuf {
1287    mana_dir
1288        .parent()
1289        .map(Path::to_path_buf)
1290        .unwrap_or_else(|| fallback.to_path_buf())
1291}
1292
1293#[derive(Debug)]
1294struct DirectUnitOutcome {
1295    event: StreamEvent,
1296    status: RunUnitStatus,
1297}
1298
1299fn worker_options_for_native_unit(
1300    unit: &mana_run_core::ReadyUnit,
1301    run_args: &NativeRunParams,
1302    workspace_root: PathBuf,
1303    mana_dir: PathBuf,
1304    ctx: &ToolContext,
1305) -> WorkerRunOptions {
1306    WorkerRunOptions {
1307        cwd: workspace_root,
1308        model_override: None,
1309        model: unit.model.clone().or_else(|| ctx.config.model.clone()),
1310        provider: None,
1311        api_key: None,
1312        thinking: ctx.config.thinking,
1313        max_turns: Some(run_args.timeout),
1314        autonomy_mode: None,
1315        verification_gates: Vec::new(),
1316        max_tokens: None,
1317        system_prompt: None,
1318        no_tools: false,
1319        mana_dir_override: Some(mana_dir),
1320        defer_verify: false,
1321        lua_loader: ctx.lua_tool_loader.clone(),
1322    }
1323}
1324
1325async fn run_direct_unit(
1326    unit: mana_run_core::ReadyUnit,
1327    round: usize,
1328    mana_dir: PathBuf,
1329    workspace_root: PathBuf,
1330    run_args: NativeRunParams,
1331    ctx: ToolContext,
1332) -> DirectUnitOutcome {
1333    let started = Instant::now();
1334    let assignment = match mana_worker::load_assignment_with_mana_dir(
1335        &workspace_root,
1336        &unit.id,
1337        Some(&mana_dir),
1338    ) {
1339        Ok(assignment) => assignment,
1340        Err(error) => {
1341            let error = error.to_string();
1342            return DirectUnitOutcome {
1343                event: StreamEvent::UnitDone {
1344                    id: unit.id.clone(),
1345                    success: false,
1346                    duration_secs: started.elapsed().as_secs(),
1347                    error: Some(error.clone()),
1348                    total_tokens: None,
1349                    total_cost: None,
1350                    tool_count: None,
1351                    turns: None,
1352                    failure_summary: Some(error.clone()),
1353                },
1354                status: RunUnitStatus {
1355                    id: unit.id,
1356                    title: unit.title,
1357                    status: "failed".to_string(),
1358                    round: Some(round),
1359                    agent: Some("imp".to_string()),
1360                    model: None,
1361                    duration_secs: Some(started.elapsed().as_secs()),
1362                    tool_count: None,
1363                    turns: None,
1364                    failure_summary: Some(error.clone()),
1365                    error: Some(error),
1366                },
1367            };
1368        }
1369    };
1370    let options = worker_options_for_native_unit(&unit, &run_args, workspace_root, mana_dir, &ctx);
1371    let outcome = mana_worker::run_worker_assignment(assignment, options).await;
1372    let duration_secs = started.elapsed().as_secs();
1373    let (success, error, tool_count, turns, failure_summary, model) = match outcome {
1374        Ok(outcome) => {
1375            let success = matches!(
1376                outcome.result.status,
1377                WorkerStatus::Completed | WorkerStatus::AwaitingVerify
1378            );
1379            (
1380                success,
1381                outcome.result.error.clone(),
1382                Some(outcome.result.tool_count),
1383                Some(outcome.result.turns),
1384                outcome.result.summary.clone(),
1385                outcome.result.model.clone(),
1386            )
1387        }
1388        Err(error) => {
1389            let error = error.to_string();
1390            (false, Some(error.clone()), None, None, Some(error), None)
1391        }
1392    };
1393    let status = RunUnitStatus {
1394        id: unit.id.clone(),
1395        title: unit.title,
1396        status: if success { "done" } else { "failed" }.to_string(),
1397        round: Some(round),
1398        agent: Some("imp".to_string()),
1399        model,
1400        duration_secs: Some(duration_secs),
1401        tool_count,
1402        turns,
1403        failure_summary: failure_summary.clone(),
1404        error: error.clone(),
1405    };
1406    DirectUnitOutcome {
1407        event: StreamEvent::UnitDone {
1408            id: unit.id,
1409            success,
1410            duration_secs,
1411            error,
1412            total_tokens: None,
1413            total_cost: None,
1414            tool_count,
1415            turns,
1416            failure_summary,
1417        },
1418        status,
1419    }
1420}
1421
1422async fn run_native_imp_orchestration(
1423    mana_dir: PathBuf,
1424    run_args: NativeRunParams,
1425    ctx: ToolContext,
1426    run_store: Arc<std::sync::Mutex<ManaRunStore>>,
1427    run_id: String,
1428) -> std::result::Result<RunView, String> {
1429    let started = Instant::now();
1430    let runtime = runtime_info_for_run(&run_args, &ctx);
1431    let stream_runtime = stream_runtime_info_for_run(&run_args, &ctx);
1432    validate_native_run_target(&run_args.target)?;
1433    let core_target = core_target_from_native(&run_args.target);
1434    let plan = mana_run_core::compute_run_plan(&mana_dir, &core_target, run_args.dry_run)
1435        .map_err(|error| error.to_string())?;
1436    let total_rounds = plan.waves.len();
1437    let all_units: Vec<_> = plan
1438        .waves
1439        .iter()
1440        .enumerate()
1441        .flat_map(|(wave_index, wave)| {
1442            wave.units.iter().map(move |unit| stream::UnitInfo {
1443                id: unit.id.clone(),
1444                title: unit.title.clone(),
1445                round: wave_index + 1,
1446            })
1447        })
1448        .collect();
1449    let run_start = StreamEvent::RunStart {
1450        parent_id: scope_from_target(&run_args.target),
1451        total_units: all_units.len(),
1452        total_rounds,
1453        units: all_units,
1454        runtime: Some(stream_runtime.clone()),
1455    };
1456    update_run_store_with_event(&run_store, &run_id, &run_start);
1457
1458    if run_args.dry_run {
1459        let summary = RunSummary {
1460            total_units: plan.total_units,
1461            total_rounds,
1462            duration_secs: started.elapsed().as_secs(),
1463            ..RunSummary::default()
1464        };
1465        let view = RunView {
1466            summary,
1467            units: run_state_snapshot(&run_store, Some(&run_id))
1468                .map(|state| state.units)
1469                .unwrap_or_default(),
1470            events: vec![run_start],
1471            runtime: Some(runtime),
1472        };
1473        finish_run_in_store(&run_store, &run_id, &view);
1474        return Ok(view);
1475    }
1476
1477    let workspace_root = mana_workspace_root(&mana_dir, &ctx.cwd);
1478    let mut events = vec![run_start];
1479    let mut total_closed = 0usize;
1480    let mut total_failed = 0usize;
1481    let mut final_units = Vec::new();
1482    let max_jobs = run_args.jobs.max(1) as usize;
1483
1484    for (wave_index, wave) in plan.waves.into_iter().enumerate() {
1485        let round = wave_index + 1;
1486        let round_start = StreamEvent::RoundStart {
1487            round,
1488            total_rounds,
1489            unit_count: wave.units.len(),
1490        };
1491        update_run_store_with_event(&run_store, &run_id, &round_start);
1492        events.push(round_start);
1493
1494        let mut success_count = 0usize;
1495        let mut failed_count = 0usize;
1496        let mut pending = wave.units.into_iter();
1497        loop {
1498            if ctx.cancelled.load(std::sync::atomic::Ordering::SeqCst) {
1499                return Err("native mana run cancelled".to_string());
1500            }
1501            let batch: Vec<_> = pending.by_ref().take(max_jobs).collect();
1502            if batch.is_empty() {
1503                break;
1504            }
1505            let mut handles = Vec::with_capacity(batch.len());
1506            for unit in batch {
1507                let unit_start = StreamEvent::UnitStart {
1508                    id: unit.id.clone(),
1509                    title: unit.title.clone(),
1510                    round,
1511                    file_overlaps: None,
1512                    attempt: None,
1513                    priority: Some(unit.priority),
1514                };
1515                update_run_store_with_event(&run_store, &run_id, &unit_start);
1516                events.push(unit_start);
1517                handles.push(tokio::spawn(run_direct_unit(
1518                    unit,
1519                    round,
1520                    mana_dir.clone(),
1521                    workspace_root.clone(),
1522                    run_args.clone(),
1523                    ctx.clone(),
1524                )));
1525            }
1526            for handle in handles {
1527                let outcome = handle
1528                    .await
1529                    .map_err(|error| format!("native mana worker task failed: {error}"))?;
1530                let success = matches!(outcome.status.status.as_str(), "done");
1531                if success {
1532                    success_count += 1;
1533                    total_closed += 1;
1534                } else {
1535                    failed_count += 1;
1536                    total_failed += 1;
1537                }
1538                update_run_store_with_event(&run_store, &run_id, &outcome.event);
1539                events.push(outcome.event);
1540                final_units.push(outcome.status);
1541            }
1542            if failed_count > 0 && !run_args.keep_going {
1543                break;
1544            }
1545        }
1546        let round_end = StreamEvent::RoundEnd {
1547            round,
1548            success_count,
1549            failed_count,
1550        };
1551        update_run_store_with_event(&run_store, &run_id, &round_end);
1552        events.push(round_end);
1553        if failed_count > 0 && !run_args.keep_going {
1554            break;
1555        }
1556    }
1557
1558    let duration_secs = started.elapsed().as_secs();
1559    let run_end = StreamEvent::RunEnd {
1560        total_success: total_closed,
1561        total_closed,
1562        total_failed,
1563        total_abandoned: 0,
1564        total_awaiting_verify: 0,
1565        total_skipped: plan.blocked.len(),
1566        duration_secs,
1567    };
1568    update_run_store_with_event(&run_store, &run_id, &run_end);
1569    events.push(run_end);
1570
1571    let summary = RunSummary {
1572        total_units: final_units.len(),
1573        total_rounds,
1574        total_closed,
1575        total_failed,
1576        total_abandoned: 0,
1577        total_awaiting_verify: 0,
1578        total_skipped: plan.blocked.len(),
1579        duration_secs,
1580    };
1581    let view = RunView {
1582        summary,
1583        units: final_units,
1584        events,
1585        runtime: Some(runtime),
1586    };
1587    finish_run_in_store(&run_store, &run_id, &view);
1588    Ok(view)
1589}
1590
1591fn spawn_background_run(
1592    mana_dir: std::path::PathBuf,
1593    run_args: NativeRunParams,
1594    ctx: ToolContext,
1595    run_store: Arc<std::sync::Mutex<ManaRunStore>>,
1596    run_id: String,
1597) {
1598    let ui = ctx.ui.clone();
1599    let command_tx = ctx.command_tx.clone();
1600    let scope = scope_from_target(&run_args.target);
1601
1602    tokio::spawn(async move {
1603        ui.set_status(
1604            "mana",
1605            Some(&format!("mana orchestration: running {scope}")),
1606        )
1607        .await;
1608        ui.set_widget("mana", Some(mana_run_widget_lines(&run_id, &scope, None)))
1609            .await;
1610
1611        let run_store_for_progress = run_store.clone();
1612        let run_id_for_progress = run_id.clone();
1613        let scope_for_progress = scope.clone();
1614        let ui_for_progress = ui.clone();
1615        let progress_task = tokio::spawn(async move {
1616            let started = Instant::now();
1617            let mut interval = tokio::time::interval(Duration::from_millis(750));
1618            loop {
1619                interval.tick().await;
1620                let state = run_state_snapshot(&run_store_for_progress, Some(&run_id_for_progress));
1621                if let Some(state) = state.as_ref() {
1622                    let status = format!(
1623                        "mana {}: {} · {}/{} done · {} failed",
1624                        run_id_for_progress,
1625                        state.status,
1626                        state.summary.total_closed,
1627                        state.summary.total_units,
1628                        state.summary.total_failed
1629                    );
1630                    ui_for_progress.set_status("mana", Some(&status)).await;
1631                    ui_for_progress
1632                        .set_widget(
1633                            "mana",
1634                            Some(mana_run_widget_lines(
1635                                &run_id_for_progress,
1636                                &scope_for_progress,
1637                                Some(state),
1638                            )),
1639                        )
1640                        .await;
1641                    if state.finished_at_ms.is_some()
1642                        || matches!(state.status.as_str(), "finished" | "failed" | "interrupted")
1643                    {
1644                        break;
1645                    }
1646                } else if started.elapsed() > Duration::from_secs(5) {
1647                    ui_for_progress
1648                        .set_status(
1649                            "mana",
1650                            Some(&format!("mana {run_id_for_progress}: waiting for events")),
1651                        )
1652                        .await;
1653                }
1654            }
1655        });
1656
1657        let result = run_native_imp_orchestration(
1658            mana_dir,
1659            run_args,
1660            ctx,
1661            run_store.clone(),
1662            run_id.clone(),
1663        )
1664        .await;
1665
1666        progress_task.abort();
1667
1668        match result {
1669            Ok(view) => {
1670                let summary = format!(
1671                    "mana orchestration: {scope} finished · {} done · {} failed",
1672                    view.summary.total_closed, view.summary.total_failed
1673                );
1674                let runtime_detail = view
1675                    .runtime
1676                    .as_ref()
1677                    .map(|runtime| {
1678                        let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
1679                        let model = runtime.model.as_deref().unwrap_or("default-model");
1680                        format!(
1681                            "native mana tool → mana orchestration → {agent} workers · {scope} · {model}"
1682                        )
1683                    })
1684                    .unwrap_or_else(|| scope.clone());
1685                ui.set_status("mana", Some(&summary)).await;
1686                ui.set_widget(
1687                    "mana",
1688                    Some(mana_widget_lines(summary.clone(), Some(runtime_detail))),
1689                )
1690                .await;
1691                ui.notify(&summary, NotifyLevel::Info).await;
1692                if !ui.has_ui() {
1693                    let _ = command_tx
1694                        .send(crate::agent::AgentCommand::FollowUp(
1695                            make_follow_up_summary(&scope, &view),
1696                        ))
1697                        .await;
1698                }
1699                let ui_clear = ui.clone();
1700                tokio::spawn(async move {
1701                    tokio::time::sleep(std::time::Duration::from_secs(12)).await;
1702                    ui_clear.set_widget("mana", None).await;
1703                    ui_clear.set_status("mana", None).await;
1704                });
1705            }
1706            Err(err) => {
1707                let message = format!("mana orchestration: {scope} failed: {err}");
1708                fail_run_in_store(&run_store, &run_id, message.clone());
1709                ui.set_status("mana", Some(&message)).await;
1710                ui.set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
1711                    .await;
1712                ui.notify(&message, NotifyLevel::Error).await;
1713                if !ui.has_ui() {
1714                    let _ = command_tx
1715                        .send(crate::agent::AgentCommand::FollowUp(format!(
1716                            "Native mana orchestration failed for {scope}: {err}. Inspect with mana(action=\"run_state\") or mana(action=\"logs\", run_id=\"{run_id}\")."
1717                        )))
1718                        .await;
1719                }
1720            }
1721        }
1722    });
1723}
1724
1725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1726enum GuideTopic {
1727    Overview,
1728    Task,
1729    Epic,
1730    Decision,
1731    Notes,
1732    Verify,
1733    Orchestrate,
1734    WorkerContext,
1735}
1736
1737impl GuideTopic {
1738    fn as_str(self) -> &'static str {
1739        match self {
1740            Self::Overview => "overview",
1741            Self::Task => "task",
1742            Self::Epic => "epic",
1743            Self::Decision => "decision",
1744            Self::Notes => "notes",
1745            Self::Verify => "verify",
1746            Self::Orchestrate => "orchestrate",
1747            Self::WorkerContext => "worker_context",
1748        }
1749    }
1750
1751    fn parse(raw: &str) -> Option<Self> {
1752        match raw {
1753            "overview" => Some(Self::Overview),
1754            "task" => Some(Self::Task),
1755            "epic" => Some(Self::Epic),
1756            "decision" => Some(Self::Decision),
1757            "notes" => Some(Self::Notes),
1758            "verify" => Some(Self::Verify),
1759            "orchestrate" => Some(Self::Orchestrate),
1760            "worker_context" => Some(Self::WorkerContext),
1761            _ => None,
1762        }
1763    }
1764}
1765
1766#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1767enum TemplateKind {
1768    Epic,
1769    Task,
1770    Fact,
1771}
1772
1773impl TemplateKind {
1774    fn as_str(self) -> &'static str {
1775        match self {
1776            Self::Epic => "epic",
1777            Self::Task => "task",
1778            Self::Fact => "fact",
1779        }
1780    }
1781
1782    fn parse(raw: &str) -> Option<Self> {
1783        match raw {
1784            "epic" => Some(Self::Epic),
1785            "task" => Some(Self::Task),
1786            "fact" => Some(Self::Fact),
1787            _ => None,
1788        }
1789    }
1790}
1791
1792fn parse_guide_topic(params: &serde_json::Value) -> Result<GuideTopic> {
1793    let topic = parse_optional_string(&params["topic"]).unwrap_or_else(|| "overview".to_string());
1794    GuideTopic::parse(&topic).ok_or_else(|| {
1795        crate::error::Error::Tool(format!(
1796            "Invalid mana guide topic '{topic}'. Use overview, task, epic, decision, notes, verify, orchestrate, or worker_context."
1797        ))
1798    })
1799}
1800
1801fn parse_optional_guide_topic(params: &serde_json::Value) -> Result<Option<GuideTopic>> {
1802    match parse_optional_string(&params["topic"]) {
1803        Some(topic) => GuideTopic::parse(&topic).map(Some).ok_or_else(|| {
1804            crate::error::Error::Tool(format!(
1805                "Invalid mana template topic '{topic}'. Use overview, task, epic, decision, notes, verify, orchestrate, or worker_context."
1806            ))
1807        }),
1808        None => Ok(None),
1809    }
1810}
1811
1812fn parse_template_kind(params: &serde_json::Value) -> Result<TemplateKind> {
1813    let kind = parse_optional_string(&params["kind"]).unwrap_or_else(|| "task".to_string());
1814    TemplateKind::parse(&kind).ok_or_else(|| {
1815        crate::error::Error::Tool(format!(
1816            "Invalid mana template kind '{kind}'. Use epic, task, or fact."
1817        ))
1818    })
1819}
1820
1821fn topic_guidance(topic: GuideTopic) -> (&'static str, Vec<&'static str>, Vec<&'static str>) {
1822    match topic {
1823        GuideTopic::Overview => (
1824            "Use mana when work needs durable scope, verification, dependencies, retries, or handoff; use direct edits for small one-pass changes.",
1825            vec![
1826                "Before creating, inspect existing relevant units with list/show and update or append notes when the new state belongs there.",
1827                "Create epics for new durable goals and tasks for new executable units, not for routine progress on existing work.",
1828                "Record decisions/notes when context should survive the turn.",
1829                "Close only after the verify command or equivalent evidence passes.",
1830            ],
1831            vec![
1832                "list status=in_progress",
1833                "show id=...",
1834                "update id=...",
1835                "template kind=task",
1836            ],
1837        ),
1838        GuideTopic::Task => (
1839            "A task is a worker-ready executable spec with clear scope, acceptance, files, and a verify gate.",
1840            vec![
1841                "Title the outcome, not the activity.",
1842                "Description should include current state, exact steps, edge cases, and non-goals.",
1843                "Acceptance and verify define done.",
1844            ],
1845            vec![
1846                "template kind=task",
1847                "create kind=task title=... verify=...",
1848            ],
1849        ),
1850        GuideTopic::Epic => (
1851            "An epic is a durable feature/spec container that decomposes into executable child tasks.",
1852            vec![
1853                "Capture goal, constraints, architecture direction, and sequencing.",
1854                "Keep implementation in child tasks with verify commands.",
1855            ],
1856            vec![
1857                "template kind=epic",
1858                "create kind=epic title=... feature=true",
1859            ],
1860        ),
1861        GuideTopic::Decision => (
1862            "Use decisions for scope, architecture, sequencing, and tradeoffs future workers should not relitigate.",
1863            vec![
1864                "Add decisions when a choice changes implementation direction.",
1865                "Resolve decisions when the blocker is answered or superseded.",
1866            ],
1867            vec![
1868                "decision_add id=... description=...",
1869                "decision_resolve id=... resolve_decisions=...",
1870            ],
1871        ),
1872        GuideTopic::Notes => (
1873            "Use notes for durable progress, diagnosis, blockers, failed attempts, and retry changes.",
1874            vec![
1875                "Append concrete evidence, commands, files, and observed errors.",
1876                "After failures, update notes before retrying with a changed plan.",
1877            ],
1878            vec!["notes_append id=... notes=..."],
1879        ),
1880        GuideTopic::Verify => (
1881            "Verification is first-class: acceptance says what must be true; verify is the command/evidence that proves it.",
1882            vec![
1883                "Use fail_first for regression tasks where the check should fail before implementation.",
1884                "Prefer narrow, repeatable commands over broad expensive checks.",
1885                "If verify is wrong, record why and use equivalent evidence explicitly.",
1886            ],
1887            vec![
1888                "create title=... acceptance=... verify=...",
1889                "verify id=...",
1890            ],
1891        ),
1892        GuideTopic::Orchestrate => (
1893            "Orchestration runs ready tasks in dependency waves and returns run_id for state/log inspection.",
1894            vec![
1895                "Create dependencies before running parallel waves.",
1896                "Use run_state/logs/agents to inspect active work.",
1897                "Update failed units with new context before retrying.",
1898            ],
1899            vec![
1900                "run targets=[...]",
1901                "run_state run_id=...",
1902                "logs run_id=...",
1903            ],
1904        ),
1905        GuideTopic::WorkerContext => (
1906            "Worker context is assembled from unit fields: title, description, acceptance, verify, paths, dependencies, notes, and decisions.",
1907            vec![
1908                "Write units so another agent can execute cold without guessing.",
1909                "Put architecture or scope choices in decisions/notes, not transient chat.",
1910            ],
1911            vec!["show id=...", "notes_append id=... notes=..."],
1912        ),
1913    }
1914}
1915
1916fn mana_guide_output(topic: GuideTopic) -> ToolOutput {
1917    let (summary, guidance, next_actions) = topic_guidance(topic);
1918    text_output(
1919        format!(
1920            "mana guide: {}\n{}\n- {}\nnext: {}",
1921            topic.as_str(),
1922            summary,
1923            guidance.join("\n- "),
1924            next_actions.join("; ")
1925        ),
1926        json!({
1927            "action": "guide",
1928            "topic": topic.as_str(),
1929            "summary": summary,
1930            "guidance": guidance,
1931            "next_actions": next_actions,
1932        }),
1933    )
1934}
1935
1936fn template_body(kind: TemplateKind, topic: Option<GuideTopic>) -> serde_json::Value {
1937    match kind {
1938        TemplateKind::Epic => json!({
1939            "kind": "epic",
1940            "title": "Outcome-oriented goal",
1941            "description": "Goal, users, constraints, architecture direction, decomposition plan, and non-goals.",
1942            "acceptance": "Child tasks cover implementation, verification, docs, and rollout risks.",
1943            "feature": true,
1944            "labels": ["feature"],
1945        }),
1946        TemplateKind::Task => json!({
1947            "kind": "task",
1948            "title": "Implement/fix concrete outcome",
1949            "description": "Current state, exact steps, relevant files, edge cases, and scope boundaries. Include enough context for a cold worker.",
1950            "acceptance": "Observable behavior or artifact that defines done.",
1951            "verify": "targeted command that proves the task",
1952            "paths": ["path/to/file"],
1953            "fail_first": topic == Some(GuideTopic::Verify),
1954        }),
1955        TemplateKind::Fact => json!({
1956            "kind": "fact",
1957            "title": "Verifiable project claim",
1958            "verify": "command that exits 0 while the claim remains true",
1959            "ttl_days": 30,
1960            "paths": ["path/to/evidence"],
1961        }),
1962    }
1963}
1964
1965fn mana_template_output(kind: TemplateKind, topic: Option<GuideTopic>) -> ToolOutput {
1966    let template = template_body(kind, topic);
1967    let topic_text = topic.map(|topic| topic.as_str()).unwrap_or("general");
1968    let summary = match kind {
1969        TemplateKind::Epic => "Epic template for durable feature/spec containers.",
1970        TemplateKind::Task => "Task template for worker-ready executable specs.",
1971        TemplateKind::Fact => "Fact template for re-checkable project claims.",
1972    };
1973    text_output(
1974        format!(
1975            "mana template: {} ({})\n{}\n{}",
1976            kind.as_str(),
1977            topic_text,
1978            summary,
1979            serde_json::to_string_pretty(&template).unwrap_or_else(|_| template.to_string())
1980        ),
1981        json!({
1982            "action": "template",
1983            "kind": kind.as_str(),
1984            "topic": topic_text,
1985            "summary": summary,
1986            "template": template,
1987            "next_actions": ["create", "update", "notes_append"],
1988        }),
1989    )
1990}
1991
1992fn text_output(text: String, details: serde_json::Value) -> ToolOutput {
1993    ToolOutput {
1994        content: vec![imp_llm::ContentBlock::Text { text }],
1995        details,
1996        is_error: false,
1997    }
1998}
1999
2000fn run_state_snapshot(
2001    run_store: &Arc<std::sync::Mutex<ManaRunStore>>,
2002    run_id: Option<&str>,
2003) -> Option<NativeRunState> {
2004    run_store
2005        .lock()
2006        .ok()
2007        .and_then(|store| store.snapshot(run_id))
2008}
2009
2010fn retry_guardrail_for_targets(
2011    mana_dir: &Path,
2012    target_ids: &[String],
2013) -> Result<Option<serde_json::Value>> {
2014    let mut blocked_units = Vec::new();
2015    for id in target_ids {
2016        let Ok(result) = mana_core::ops::show::get(mana_dir, id) else {
2017            continue;
2018        };
2019        let unit = result.unit;
2020        let Some(attempt) = unit.attempt_log.last() else {
2021            continue;
2022        };
2023        if !matches!(
2024            attempt.outcome,
2025            mana_core::unit::types::AttemptOutcome::Failed
2026        ) {
2027            continue;
2028        }
2029        let Some(finished_at) = attempt.finished_at else {
2030            continue;
2031        };
2032        if unit.updated_at <= finished_at {
2033            blocked_units.push(json!({
2034                "id": unit.id,
2035                "title": unit.title,
2036                "failed_at": finished_at,
2037                "updated_at": unit.updated_at,
2038                "last_failure": attempt.notes,
2039            }));
2040        }
2041    }
2042
2043    if blocked_units.is_empty() {
2044        return Ok(None);
2045    }
2046
2047    Ok(Some(json!({
2048        "retry_requires_unit_update": true,
2049        "blocked_units": blocked_units,
2050        "next_actions": [
2051            "Inspect failed unit with mana action=show id=<unit>",
2052            "Append notes with failure evidence and a changed retry plan",
2053            "Retry only after updating the failed unit"
2054        ],
2055    })))
2056}
2057
2058fn run_recovery_details(state: &NativeRunState) -> serde_json::Value {
2059    let failed_units: Vec<_> = state
2060        .units
2061        .iter()
2062        .filter(|unit| unit.status == "failed")
2063        .map(|unit| {
2064            json!({
2065                "id": unit.id,
2066                "title": unit.title,
2067                "failure_summary": unit.failure_summary,
2068                "error": unit.error,
2069            })
2070        })
2071        .collect();
2072    let running_units: Vec<_> = state
2073        .units
2074        .iter()
2075        .filter(|unit| unit.status == "running")
2076        .map(|unit| json!({ "id": unit.id, "title": unit.title, "agent": unit.agent }))
2077        .collect();
2078    let awaiting_verify_units: Vec<_> = state
2079        .units
2080        .iter()
2081        .filter(|unit| unit.status == "awaiting_verify")
2082        .map(|unit| json!({ "id": unit.id, "title": unit.title }))
2083        .collect();
2084    let interrupted = state.status == "interrupted";
2085    let last_event_at_ms = if state.last_event_at_ms > 0 {
2086        state.last_event_at_ms
2087    } else {
2088        state.finished_at_ms.unwrap_or(state.started_at_ms)
2089    };
2090    let mut next_actions = Vec::new();
2091    let mut retry_requires_unit_update = false;
2092
2093    if interrupted {
2094        next_actions.push(
2095            "Run was marked interrupted/stale after reload; inspect logs before rerun".to_string(),
2096        );
2097        next_actions.push("Do not assume in-flight workers or tools are still running".to_string());
2098    }
2099    if !failed_units.is_empty() || state.status == "failed" || state.summary.total_failed > 0 {
2100        retry_requires_unit_update = true;
2101        next_actions.push("Inspect failed units with mana action=show id=<unit>".to_string());
2102        next_actions.push(
2103            "Append notes with the failure evidence and changed retry plan before rerun"
2104                .to_string(),
2105        );
2106        next_actions
2107            .push("Retry only after updating failed units; do not rerun unchanged".to_string());
2108    }
2109    if !awaiting_verify_units.is_empty() || state.summary.total_awaiting_verify > 0 {
2110        next_actions.push("Verify candidate-complete units or close with equivalent evidence if stored verify is stale".to_string());
2111    }
2112    if state.status == "running" || state.status == "starting" || !running_units.is_empty() {
2113        next_actions.push("Inspect logs/agents before assuming the run is stale".to_string());
2114    }
2115    if next_actions.is_empty() {
2116        next_actions.push("No recovery action required".to_string());
2117    }
2118
2119    json!({
2120        "status": state.status,
2121        "failed_units": failed_units,
2122        "running_units": running_units,
2123        "awaiting_verify_units": awaiting_verify_units,
2124        "stale_workers": if interrupted { json!([{"run_id": state.run_id, "status": state.status}]) } else { json!([]) },
2125        "last_event_at_ms": last_event_at_ms,
2126        "next_actions": next_actions,
2127        "retry_requires_unit_update": retry_requires_unit_update,
2128    })
2129}
2130
2131fn run_state_details(state: &NativeRunState) -> serde_json::Value {
2132    let mut details = serde_json::to_value(state).unwrap_or(serde_json::Value::Null);
2133    if let Some(object) = details.as_object_mut() {
2134        object.insert("recovery".to_string(), run_recovery_details(state));
2135    }
2136    details
2137}
2138
2139fn run_state_output(state: &NativeRunState) -> ToolOutput {
2140    let mut lines = vec![format!(
2141        "Native mana orchestration {}: {} · {}",
2142        state.run_id, state.scope, state.status
2143    )];
2144    if let Some(runtime) = &state.runtime {
2145        let agent = runtime["direct_agent"].as_str().unwrap_or("imp-worker");
2146        let model = runtime["model"].as_str().unwrap_or("default-model");
2147        lines.push(format!("Worker runtime: {agent} · {model}"));
2148    }
2149    lines.push(format!(
2150        "{} total · {} done · {} failed · {} candidate complete / awaiting verify · {} skipped",
2151        state.summary.total_units,
2152        state.summary.total_closed,
2153        state.summary.total_failed,
2154        state.summary.total_awaiting_verify,
2155        state.summary.total_skipped
2156    ));
2157
2158    if state.status == "interrupted" {
2159        lines.push(
2160            "Interrupted: persisted running state is stale; inspect logs before rerun".to_string(),
2161        );
2162    }
2163
2164    if !state.units.is_empty() {
2165        let preview = state
2166            .units
2167            .iter()
2168            .take(3)
2169            .map(|unit| format!("{}:{}", unit.id, unit.status))
2170            .collect::<Vec<_>>()
2171            .join(", ");
2172        lines.push(format!("Units: {preview}"));
2173    }
2174
2175    if let Some(last) = state.log_lines.last() {
2176        lines.push(format!("Latest: {last}"));
2177    }
2178    let recovery = run_recovery_details(state);
2179    if let Some(actions) = recovery["next_actions"].as_array() {
2180        if let Some(first) = actions.first().and_then(|value| value.as_str()) {
2181            lines.push(format!("Next: {first}"));
2182        }
2183    }
2184    text_output(lines.join("\n"), run_state_details(state))
2185}
2186
2187fn evaluate_run_output(state: &NativeRunState) -> ToolOutput {
2188    let headline = match state.status.as_str() {
2189        "starting" | "running" => {
2190            format!(
2191                "Native mana orchestration run {} is still running for {}.",
2192                state.run_id, state.scope
2193            )
2194        }
2195        "failed" => format!(
2196            "Native mana orchestration run {} failed for {}.",
2197            state.run_id, state.scope
2198        ),
2199        _ if state.summary.total_failed > 0 => format!(
2200            "Native mana orchestration run {} finished with {} failed unit(s).",
2201            state.run_id, state.summary.total_failed
2202        ),
2203        _ if state.summary.total_awaiting_verify > 0 => format!(
2204            "Native mana orchestration run {} finished with {} unit(s) candidate complete / awaiting verify.",
2205            state.run_id, state.summary.total_awaiting_verify
2206        ),
2207        _ => format!(
2208            "Native mana orchestration run {} finished successfully: {} unit(s) done.",
2209            state.run_id, state.summary.total_closed
2210        ),
2211    };
2212
2213    let runtime = state
2214        .runtime
2215        .as_ref()
2216        .map(|runtime| {
2217            format!(
2218                "Worker runtime: {} · {}",
2219                runtime["direct_agent"].as_str().unwrap_or("imp-worker"),
2220                runtime["model"].as_str().unwrap_or("default-model")
2221            )
2222        })
2223        .unwrap_or_else(|| "Runtime: unknown".to_string());
2224
2225    let latest = state
2226        .log_lines
2227        .last()
2228        .map(|line| format!("Latest: {line}"))
2229        .unwrap_or_else(|| "Latest: (no stream events captured yet)".to_string());
2230
2231    let recovery = run_recovery_details(state);
2232    let next = recovery["next_actions"]
2233        .as_array()
2234        .and_then(|actions| actions.first())
2235        .and_then(|value| value.as_str())
2236        .map(|action| format!("Next: {action}"))
2237        .unwrap_or_else(|| "Next: No recovery action required".to_string());
2238
2239    text_output(
2240        format!("{headline}\n{runtime}\n{latest}\n{next}"),
2241        run_state_details(state),
2242    )
2243}
2244
2245fn claim_output(result: &mana_core::ops::claim::ClaimResult) -> ToolOutput {
2246    let text = format!(
2247        "Claimed unit {} ({}) by {}",
2248        result.unit.id, result.unit.title, result.claimer
2249    );
2250    ToolOutput {
2251        content: vec![imp_llm::ContentBlock::Text { text }],
2252        details: json!({
2253            "unit": {
2254                "id": result.unit.id,
2255                "title": result.unit.title,
2256                "status": result.unit.status,
2257                "claimed_by": result.unit.claimed_by,
2258            },
2259            "claimer": result.claimer,
2260            "is_goal": result.is_goal,
2261            "path": result.path,
2262        }),
2263        is_error: false,
2264    }
2265}
2266
2267fn release_output(result: &mana_core::ops::claim::ReleaseResult) -> ToolOutput {
2268    let text = format!(
2269        "Released unit {} ({}) back to {}",
2270        result.unit.id, result.unit.title, result.unit.status
2271    );
2272    ToolOutput {
2273        content: vec![imp_llm::ContentBlock::Text { text }],
2274        details: json!({
2275            "unit": {
2276                "id": result.unit.id,
2277                "title": result.unit.title,
2278                "status": result.unit.status,
2279                "claimed_by": result.unit.claimed_by,
2280            },
2281            "path": result.path,
2282        }),
2283        is_error: false,
2284    }
2285}
2286
2287fn truncate_with_note(text: &str) -> String {
2288    let result = truncate_head(text, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
2289    if !result.truncated {
2290        return result.content;
2291    }
2292
2293    let mut output = result.content;
2294    output.push_str(&format!(
2295        "\n[Output truncated: showing first {} of {} lines{}]",
2296        result.output_lines,
2297        result.total_lines,
2298        result
2299            .temp_file
2300            .as_ref()
2301            .map(|p| format!(". Full output saved to {}", p.display()))
2302            .unwrap_or_default()
2303    ));
2304    output
2305}
2306
2307fn compact_list_output(
2308    entries: &[mana_core::index::IndexEntry],
2309    requested_limit: Option<usize>,
2310) -> ToolOutput {
2311    let limit = requested_limit
2312        .unwrap_or(DEFAULT_LIST_LIMIT)
2313        .clamp(1, MAX_LIST_LIMIT);
2314    let shown = entries.len().min(limit);
2315    let units: Vec<_> = entries
2316        .iter()
2317        .take(limit)
2318        .map(|entry| {
2319            json!({
2320                "id": entry.id,
2321                "title": entry.title,
2322                "status": entry.status,
2323                "priority": entry.priority,
2324                "kind": entry.kind,
2325                "parent": entry.parent,
2326                "labels": entry.labels,
2327                "updated_at": entry.updated_at,
2328                "claimed_by": entry.claimed_by,
2329                "has_verify": entry.has_verify,
2330                "attempts": entry.attempts,
2331                "paths": entry.paths,
2332            })
2333        })
2334        .collect();
2335
2336    let mut lines = vec![format!(
2337        "mana list: showing {shown} of {} units. Prefer `show` + `update`/`notes_append` on an existing relevant unit before `create`.",
2338        entries.len()
2339    )];
2340    for entry in entries.iter().take(limit) {
2341        let parent = entry
2342            .parent
2343            .as_ref()
2344            .map(|parent| format!(" parent={parent}"))
2345            .unwrap_or_default();
2346        let claimed = entry
2347            .claimed_by
2348            .as_ref()
2349            .map(|claimed_by| format!(" claimed={claimed_by}"))
2350            .unwrap_or_default();
2351        lines.push(format!(
2352            "- {} P{} {:?} {:?}{}{} — {}",
2353            entry.id, entry.priority, entry.status, entry.kind, parent, claimed, entry.title
2354        ));
2355    }
2356    if entries.len() > shown {
2357        lines.push(format!(
2358            "[{} more omitted; narrow with status/parent/label/priority or request all only when needed]",
2359            entries.len() - shown
2360        ));
2361    }
2362
2363    text_output(
2364        lines.join("\n"),
2365        json!({
2366            "action": "list",
2367            "total": entries.len(),
2368            "shown": shown,
2369            "limit": limit,
2370            "truncated": entries.len() > shown,
2371            "hint": "Prefer updating existing relevant units; use show id=... before create when unsure.",
2372            "units": units,
2373        }),
2374    )
2375}
2376
2377fn scored_units_to_text(units: &[ScoredUnit]) -> String {
2378    if units.is_empty() {
2379        return "No ready units. Create one with: mana create \"task\" --verify \"cmd\""
2380            .to_string();
2381    }
2382
2383    let mut lines = Vec::new();
2384    for unit in units {
2385        lines.push(format!(
2386            "P{}  {:.1}  {}",
2387            unit.priority, unit.score, unit.title
2388        ));
2389        if !unit.unblocks.is_empty() {
2390            lines.push(format!("      Unblocks: {}", unit.unblocks.join(", ")));
2391        }
2392        let attempts = if unit.attempts > 0 {
2393            format!(" | Attempts: {}", unit.attempts)
2394        } else {
2395            String::new()
2396        };
2397        lines.push(format!(
2398            "      ID: {} | Age: {} days{}",
2399            unit.id, unit.age_days, attempts
2400        ));
2401        lines.push(String::new());
2402    }
2403    lines.join("\n")
2404}
2405
2406fn tree_lines(node: &mana_core::api::TreeNode, indent: usize, out: &mut Vec<String>) {
2407    let prefix = "  ".repeat(indent);
2408    let verify = if node.has_verify { "spec" } else { "goal" };
2409    out.push(format!(
2410        "{}{} {} [{} P{} · {}]",
2411        prefix, node.id, node.title, node.status, node.priority, verify
2412    ));
2413    for child in &node.children {
2414        tree_lines(child, indent + 1, out);
2415    }
2416}
2417
2418pub struct ManaTool {
2419    run_store: Arc<std::sync::Mutex<ManaRunStore>>,
2420}
2421
2422impl Default for ManaTool {
2423    fn default() -> Self {
2424        Self {
2425            run_store: Arc::new(std::sync::Mutex::new(ManaRunStore::load_persisted())),
2426        }
2427    }
2428}
2429
2430#[async_trait]
2431impl Tool for ManaTool {
2432    fn name(&self) -> &str {
2433        "mana"
2434    }
2435    fn label(&self) -> &str {
2436        "Mana"
2437    }
2438    fn description(&self) -> &str {
2439        "Mana work graph operations. Use guide/template for detailed usage."
2440    }
2441    fn parameters(&self) -> serde_json::Value {
2442        let string_or_array = || {
2443            json!({
2444                "oneOf": [
2445                    { "type": "string" },
2446                    { "type": "array", "items": { "type": "string" } }
2447                ]
2448            })
2449        };
2450
2451        let mut properties = serde_json::Map::new();
2452        properties.insert(
2453            "action".into(),
2454            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", "reparent", "guide", "template"] }),
2455        );
2456        properties.insert("id".into(), json!({ "type": "string" }));
2457        properties.insert(
2458            "scope".into(),
2459            json!({ "type": "string", "enum": ["auto", "project", "root"], "description": "auto|project|root" }),
2460        );
2461        properties.insert(
2462            "path".into(),
2463            json!({ "type": "string", "description": "Project/.mana path" }),
2464        );
2465        properties.insert(
2466            "from_id".into(),
2467            json!({ "type": "string", "description": "Source unit ID" }),
2468        );
2469        properties.insert(
2470            "dep_id".into(),
2471            json!({ "type": "string", "description": "Dependency unit ID" }),
2472        );
2473        properties.insert(
2474            "run_id".into(),
2475            json!({ "type": "string", "description": "Run ID from action=run" }),
2476        );
2477        properties.insert("title".into(), json!({ "type": "string" }));
2478        properties.insert(
2479            "topic".into(),
2480            json!({ "type": "string", "enum": ["overview", "task", "epic", "decision", "notes", "verify", "orchestrate", "worker_context"], "description": "guide/template topic" }),
2481        );
2482        properties.insert(
2483            "verify".into(),
2484            json!({ "type": "string", "description": "Verify shell command" }),
2485        );
2486        properties.insert("description".into(), json!({ "type": "string" }));
2487        properties.insert(
2488            "acceptance".into(),
2489            json!({ "type": "string", "description": "Acceptance criteria" }),
2490        );
2491        properties.insert(
2492            "notes".into(),
2493            json!({ "type": "string", "description": "Progress notes" }),
2494        );
2495        properties.insert(
2496            "design".into(),
2497            json!({ "type": "string", "description": "Design context" }),
2498        );
2499        properties.insert(
2500            "assignee".into(),
2501            json!({ "type": "string", "description": "Assignee/owner" }),
2502        );
2503        properties.insert("parent".into(), json!({ "type": "string" }));
2504        properties.insert(
2505            "parent_reason".into(),
2506            json!({ "type": "string", "description": "Why this parent" }),
2507        );
2508        let mut deps = string_or_array();
2509        deps["description"] = json!("Dependency IDs");
2510        properties.insert("deps".into(), deps);
2511        let mut produces = string_or_array();
2512        produces["description"] = json!("Produced artifacts");
2513        properties.insert("produces".into(), produces);
2514        let mut requires = string_or_array();
2515        requires["description"] = json!("Required artifacts");
2516        properties.insert("requires".into(), requires);
2517        let mut paths = string_or_array();
2518        paths["description"] = json!("Relevant paths");
2519        properties.insert("paths".into(), paths);
2520        let mut decisions = string_or_array();
2521        decisions["description"] = json!("Blocking decisions");
2522        properties.insert("decisions".into(), decisions);
2523        let mut resolve_decisions = string_or_array();
2524        resolve_decisions["description"] = json!("Decision IDs/indexes to resolve");
2525        properties.insert("resolve_decisions".into(), resolve_decisions);
2526        properties.insert("status".into(), json!({ "type": "string" }));
2527        properties.insert("priority".into(), json!({ "type": "integer" }));
2528        let mut labels = string_or_array();
2529        labels["description"] = json!("Labels");
2530        properties.insert("labels".into(), labels);
2531        properties.insert(
2532            "kind".into(),
2533            json!({ "type": "string", "enum": ["epic", "task", "fact"], "description": "Unit type" }),
2534        );
2535        properties.insert(
2536            "feature".into(),
2537            json!({ "type": "boolean", "description": "Feature-level goal" }),
2538        );
2539        properties.insert(
2540            "fail_first".into(),
2541            json!({ "type": "boolean", "description": "Verify should fail first" }),
2542        );
2543        properties.insert(
2544            "verify_timeout".into(),
2545            json!({ "type": "integer", "description": "Verify timeout seconds" }),
2546        );
2547        properties.insert(
2548            "on_fail".into(),
2549            json!({ "description": "retry:3 or escalate:P1" }),
2550        );
2551        properties.insert(
2552            "ttl_days".into(),
2553            json!({ "type": "integer", "description": "Fact TTL days" }),
2554        );
2555        properties.insert(
2556            "pass_ok".into(),
2557            json!({ "type": "boolean", "description": "Allow passing fact verify" }),
2558        );
2559        properties.insert(
2560            "force".into(),
2561            json!({ "type": "boolean", "description": "Bypass close verify with reason" }),
2562        );
2563        properties.insert("reason".into(), json!({ "type": "string" }));
2564        properties.insert("all".into(), json!({ "type": "boolean" }));
2565        properties.insert(
2566            "by".into(),
2567            json!({ "type": "string", "description": "Claimer" }),
2568        );
2569        properties.insert(
2570            "count".into(),
2571            json!({ "type": "integer", "description": "Max results" }),
2572        );
2573        properties.insert(
2574            "targets".into(),
2575            json!({ "type": "array", "items": { "type": "string" }, "description": "Target unit IDs" }),
2576        );
2577        properties.insert("jobs".into(), json!({ "type": "integer" }));
2578        properties.insert("dry_run".into(), json!({ "type": "boolean" }));
2579        properties.insert("loop".into(), json!({ "type": "boolean" }));
2580        properties.insert("keep_going".into(), json!({ "type": "boolean" }));
2581        properties.insert("timeout".into(), json!({ "type": "integer" }));
2582        properties.insert("idle_timeout".into(), json!({ "type": "integer" }));
2583        properties.insert("review".into(), json!({ "type": "boolean" }));
2584
2585        serde_json::Value::Object(serde_json::Map::from_iter([
2586            ("type".into(), json!("object")),
2587            ("properties".into(), serde_json::Value::Object(properties)),
2588            ("required".into(), json!(["action"])),
2589        ]))
2590    }
2591    fn is_readonly(&self) -> bool {
2592        false
2593    }
2594
2595    async fn execute(
2596        &self,
2597        _call_id: &str,
2598        params: serde_json::Value,
2599        ctx: ToolContext,
2600    ) -> Result<ToolOutput> {
2601        let action = params["action"]
2602            .as_str()
2603            .ok_or_else(|| crate::error::Error::Tool("missing 'action' parameter".into()))?;
2604
2605        let mode = ctx.mode;
2606
2607        match action {
2608            "guide" => return Ok(mana_guide_output(parse_guide_topic(&params)?)),
2609            "template" => {
2610                return Ok(mana_template_output(
2611                    parse_template_kind(&params)?,
2612                    parse_optional_guide_topic(&params)?,
2613                ));
2614            }
2615            _ => {}
2616        }
2617
2618        if !mode.allows_mana_action(action) {
2619            let mode_name = format!("{mode:?}").to_lowercase();
2620            return Ok(ToolOutput::error(format!(
2621                "Mana action '{action}' is not available in {mode_name} mode"
2622            )));
2623        }
2624
2625        if let Some(validation_error) = validate_mana_action(action, &params) {
2626            return Ok(validation_error);
2627        }
2628
2629        let mana_dir = resolve_mana_dir(&ctx.cwd, &params).map_err(crate::error::Error::Tool)?;
2630
2631        match action {
2632            "status" => match mana_core::api::get_status(&mana_dir) {
2633                Ok(status) => Ok(json_output(&status)),
2634                Err(e) => Ok(ToolOutput::error(e.to_string())),
2635            },
2636            "list" => {
2637                let list_params = mana_core::ops::list::ListParams {
2638                    status: params["status"].as_str().map(|s| s.to_string()),
2639                    priority: params["priority"].as_u64().map(|p| p as u8),
2640                    parent: params["parent"].as_str().map(|s| s.to_string()),
2641                    label: params["label"].as_str().map(|s| s.to_string()),
2642                    assignee: None,
2643                    current_user: None,
2644                    include_closed: params["all"].as_bool().unwrap_or(false),
2645                };
2646                match mana_core::api::list_units(&mana_dir, &list_params) {
2647                    Ok(entries) => Ok(compact_list_output(
2648                        &entries,
2649                        params["count"].as_u64().map(|count| count as usize),
2650                    )),
2651                    Err(e) => {
2652                        let message = format!("mana run failed: {e}");
2653                        ctx.ui
2654                            .set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
2655                            .await;
2656                        ctx.ui.set_status("mana", Some(&message)).await;
2657                        Ok(ToolOutput::error(e.to_string()))
2658                    }
2659                }
2660            }
2661            "show" => {
2662                let id = params["id"]
2663                    .as_str()
2664                    .ok_or_else(|| crate::error::Error::Tool("show requires 'id'".into()))?;
2665                match mana_core::ops::show::get(&mana_dir, id) {
2666                    Ok(result) => Ok(json_output(&result.unit)),
2667                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2668                }
2669            }
2670            "create" => {
2671                let title = params["title"]
2672                    .as_str()
2673                    .ok_or_else(|| crate::error::Error::Tool("create requires 'title'".into()))?;
2674                let dependencies = parse_csv_strings(&params["deps"], "deps")?;
2675                let labels = parse_csv_strings(&params["labels"], "labels")?;
2676                let produces = parse_csv_strings(&params["produces"], "produces")?;
2677                let requires = parse_csv_strings(&params["requires"], "requires")?;
2678                let paths = parse_csv_strings(&params["paths"], "paths")?;
2679                let decisions = parse_csv_strings(&params["decisions"], "decisions")?;
2680                let on_fail = parse_on_fail(&params["on_fail"])?;
2681                let kind = parse_unit_kind(&params["kind"])?;
2682
2683                let create_params = mana_core::ops::create::CreateParams {
2684                    title: title.to_string(),
2685                    handle: None,
2686                    description: parse_optional_string(&params["description"]),
2687                    acceptance: parse_optional_string(&params["acceptance"]),
2688                    notes: parse_optional_string(&params["notes"]),
2689                    design: parse_optional_string(&params["design"]),
2690                    verify: parse_optional_string(&params["verify"]),
2691                    priority: params["priority"].as_u64().map(|p| p as u8),
2692                    labels,
2693                    assignee: parse_optional_string(&params["assignee"]),
2694                    dependencies,
2695                    parent: parse_optional_string(&params["parent"]),
2696                    produces,
2697                    requires,
2698                    paths,
2699                    on_fail,
2700                    fail_first: params["fail_first"].as_bool().unwrap_or(false),
2701                    feature: params["feature"].as_bool().unwrap_or(false),
2702                    kind,
2703                    verify_timeout: params["verify_timeout"].as_u64(),
2704                    decisions,
2705                    force: params["force"].as_bool().unwrap_or(false),
2706                };
2707                match mana_core::api::create_unit(&mana_dir, create_params) {
2708                    Ok(result) => {
2709                        let unit_value =
2710                            serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2711                        let summary = unit_delta_label(&unit_value)
2712                            .map(|label| format!("mana delta: created {label}"))
2713                            .unwrap_or_else(|| "mana delta: created unit".to_string());
2714                        let parent = parse_optional_string(&params["parent"]);
2715                        let parent_reason = parse_optional_string(&params["parent_reason"]);
2716                        let detail = parent
2717                            .as_ref()
2718                            .map(|parent| match parent_reason.as_deref() {
2719                                Some(reason) => format!("parent {parent}: {reason}"),
2720                                None => format!("parent {parent}; parent_reason missing"),
2721                            });
2722                        set_mana_delta_widget(&ctx, summary.clone(), detail).await;
2723                        Ok(text_output(
2724                            summary,
2725                            json!({
2726                                "action": "create",
2727                                "title": title,
2728                                "description": params["description"],
2729                                "verify": params["verify"],
2730                                "priority": params["priority"],
2731                                "parent": params["parent"],
2732                                "parent_reason": params["parent_reason"],
2733                                "placement": parent_placement_details(parent.as_deref(), parent_reason.as_deref()),
2734                                "deps": params["deps"],
2735                                "labels": params["labels"],
2736                                "unit": unit_value,
2737                                "path": result.path,
2738                            }),
2739                        ))
2740                    }
2741                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2742                }
2743            }
2744            "claim" => {
2745                let id = params["id"]
2746                    .as_str()
2747                    .ok_or_else(|| crate::error::Error::Tool("claim requires 'id'".into()))?;
2748                let claim_params = ClaimParams {
2749                    by: params["by"].as_str().map(|s| s.to_string()),
2750                    force: params["force"].as_bool().unwrap_or(true),
2751                };
2752                match mana_core::api::claim_unit(&mana_dir, id, claim_params) {
2753                    Ok(result) => Ok(claim_output(&result)),
2754                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2755                }
2756            }
2757            "release" => {
2758                let id = params["id"]
2759                    .as_str()
2760                    .ok_or_else(|| crate::error::Error::Tool("release requires 'id'".into()))?;
2761                match mana_core::api::release_unit(&mana_dir, id) {
2762                    Ok(result) => Ok(release_output(&result)),
2763                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2764                }
2765            }
2766            "close" => {
2767                let id = params["id"]
2768                    .as_str()
2769                    .ok_or_else(|| crate::error::Error::Tool("close requires 'id'".into()))?;
2770                let force = params["force"].as_bool().unwrap_or(false);
2771                let reason = params["reason"].as_str().map(|s| s.to_string());
2772                if force
2773                    && reason
2774                        .as_deref()
2775                        .map(str::trim)
2776                        .unwrap_or_default()
2777                        .is_empty()
2778                {
2779                    return Ok(mana_close_force_reason_error(id));
2780                }
2781                let opts = mana_core::ops::close::CloseOpts {
2782                    reason: reason.clone(),
2783                    force,
2784                    defer_verify: false,
2785                };
2786                match mana_core::api::close_unit(&mana_dir, id, opts) {
2787                    Ok(outcome) => {
2788                        let details =
2789                            serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
2790                        if let Some(unit) = details.get("unit") {
2791                            let summary = unit_delta_label(unit)
2792                                .map(|label| format!("mana delta: closed {label}"))
2793                                .unwrap_or_else(|| format!("mana delta: closed {id}"));
2794                            set_mana_delta_widget(&ctx, summary, reason.clone()).await;
2795                        }
2796                        let mut details_obj = details.as_object().cloned().unwrap_or_default();
2797                        details_obj.insert("action".into(), json!("close"));
2798                        details_obj.insert("force".into(), json!(force));
2799                        if let Some(reason) = reason.as_deref() {
2800                            details_obj.insert("reason".into(), json!(reason));
2801                            if force {
2802                                details_obj
2803                                    .insert("equivalent_verify_evidence".into(), json!(reason));
2804                            }
2805                        }
2806                        Ok(text_output(
2807                            format!("Closed unit {id}"),
2808                            serde_json::Value::Object(details_obj),
2809                        ))
2810                    }
2811                    Err(e) => Ok(mana_close_error_output(id, e.to_string())),
2812                }
2813            }
2814            "update" => {
2815                let id = params["id"]
2816                    .as_str()
2817                    .ok_or_else(|| crate::error::Error::Tool("update requires 'id'".into()))?;
2818                let decisions = parse_csv_strings(&params["decisions"], "decisions")?;
2819                let resolve_decisions =
2820                    parse_csv_strings(&params["resolve_decisions"], "resolve_decisions")?;
2821                let labels = parse_csv_strings(&params["labels"], "labels")?;
2822                let update_params = mana_core::ops::update::UpdateParams {
2823                    title: parse_optional_string(&params["title"]),
2824                    description: parse_optional_string(&params["description"]),
2825                    acceptance: parse_optional_string(&params["acceptance"]),
2826                    notes: parse_optional_string(&params["notes"]),
2827                    design: parse_optional_string(&params["design"]),
2828                    status: parse_optional_string(&params["status"]),
2829                    priority: params["priority"].as_u64().map(|p| p as u8),
2830                    assignee: parse_optional_string(&params["assignee"]),
2831                    add_label: labels
2832                        .into_iter()
2833                        .next()
2834                        .or_else(|| parse_optional_string(&params["add_label"])),
2835                    remove_label: parse_optional_string(&params["remove_label"]),
2836                    decisions,
2837                    resolve_decisions,
2838                };
2839                match mana_core::api::update_unit(&mana_dir, id, update_params) {
2840                    Ok(result) => {
2841                        let unit_value =
2842                            serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2843                        let summary = unit_delta_label(&unit_value)
2844                            .map(|label| format!("mana delta: updated {label}"))
2845                            .unwrap_or_else(|| format!("mana delta: updated {id}"));
2846                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
2847                        Ok(text_output(
2848                            summary,
2849                            json!({
2850                                "action": "update",
2851                                "id": id,
2852                                "status": params["status"],
2853                                "title": params["title"],
2854                                "description": params["description"],
2855                                "priority": params["priority"],
2856                                "notes": params["notes"],
2857                                "acceptance": params["acceptance"],
2858                                "add_label": params["add_label"],
2859                                "remove_label": params["remove_label"],
2860                                "decisions": params["decisions"],
2861                                "resolve_decisions": params["resolve_decisions"],
2862                                "unit": unit_value,
2863                                "path": result.path,
2864                            }),
2865                        ))
2866                    }
2867                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2868                }
2869            }
2870            "notes_append" => {
2871                let id = params["id"].as_str().ok_or_else(|| {
2872                    crate::error::Error::Tool("notes_append requires 'id'".into())
2873                })?;
2874                let note = parse_optional_string(&params["notes"]).ok_or_else(|| {
2875                    crate::error::Error::Tool("notes_append requires 'notes'".into())
2876                })?;
2877                let update_params = mana_core::ops::update::UpdateParams {
2878                    title: None,
2879                    description: None,
2880                    acceptance: None,
2881                    notes: Some(note),
2882                    design: None,
2883                    status: None,
2884                    priority: None,
2885                    assignee: None,
2886                    add_label: None,
2887                    remove_label: None,
2888                    decisions: Vec::new(),
2889                    resolve_decisions: Vec::new(),
2890                };
2891                match mana_core::api::update_unit(&mana_dir, id, update_params) {
2892                    Ok(result) => {
2893                        let unit_value =
2894                            serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2895                        let summary = unit_delta_label(&unit_value)
2896                            .map(|label| format!("mana delta: notes appended on {label}"))
2897                            .unwrap_or_else(|| format!("mana delta: notes appended on {id}"));
2898                        set_mana_delta_widget(&ctx, summary.clone(), Some("notes appended".into()))
2899                            .await;
2900                        Ok(text_output(
2901                            summary,
2902                            json!({
2903                                "action": "notes_append",
2904                                "id": id,
2905                                "notes": params["notes"],
2906                                "unit": unit_value,
2907                                "path": result.path,
2908                            }),
2909                        ))
2910                    }
2911                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2912                }
2913            }
2914            "decision_add" => {
2915                let id = params["id"].as_str().ok_or_else(|| {
2916                    crate::error::Error::Tool("decision_add requires 'id'".into())
2917                })?;
2918                let decision = parse_optional_string(&params["description"])
2919                    .or_else(|| {
2920                        parse_csv_strings(&params["decisions"], "decisions")
2921                            .ok()
2922                            .and_then(|mut decisions| decisions.drain(..).next())
2923                    })
2924                    .or_else(|| parse_optional_string(&params["notes"]))
2925                    .ok_or_else(|| {
2926                        crate::error::Error::Tool(
2927                            "decision_add requires 'description' or 'decisions'".into(),
2928                        )
2929                    })?;
2930                let update_params = mana_core::ops::update::UpdateParams {
2931                    title: None,
2932                    description: None,
2933                    acceptance: None,
2934                    notes: None,
2935                    design: None,
2936                    status: None,
2937                    priority: None,
2938                    assignee: None,
2939                    add_label: None,
2940                    remove_label: None,
2941                    decisions: vec![decision],
2942                    resolve_decisions: Vec::new(),
2943                };
2944                match mana_core::api::update_unit(&mana_dir, id, update_params) {
2945                    Ok(result) => {
2946                        let unit_value =
2947                            serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2948                        let summary = unit_delta_label(&unit_value)
2949                            .map(|label| format!("mana delta: decision added on {label}"))
2950                            .unwrap_or_else(|| format!("mana delta: decision added on {id}"));
2951                        set_mana_delta_widget(&ctx, summary.clone(), Some("decision added".into()))
2952                            .await;
2953                        Ok(text_output(
2954                            summary,
2955                            json!({
2956                                "action": "decision_add",
2957                                "id": id,
2958                                "description": params["description"],
2959                                "unit": unit_value,
2960                                "path": result.path,
2961                            }),
2962                        ))
2963                    }
2964                    Err(e) => Ok(ToolOutput::error(e.to_string())),
2965                }
2966            }
2967            "decision_resolve" => {
2968                let id = params["id"].as_str().ok_or_else(|| {
2969                    crate::error::Error::Tool("decision_resolve requires 'id'".into())
2970                })?;
2971                let resolve_decisions =
2972                    parse_csv_strings(&params["resolve_decisions"], "resolve_decisions")?;
2973                if resolve_decisions.is_empty() {
2974                    return Ok(ToolOutput::error(
2975                        "decision_resolve requires 'resolve_decisions'",
2976                    ));
2977                }
2978                let update_params = mana_core::ops::update::UpdateParams {
2979                    title: None,
2980                    description: None,
2981                    acceptance: None,
2982                    notes: None,
2983                    design: None,
2984                    status: None,
2985                    priority: None,
2986                    assignee: None,
2987                    add_label: None,
2988                    remove_label: None,
2989                    decisions: Vec::new(),
2990                    resolve_decisions,
2991                };
2992                match mana_core::api::update_unit(&mana_dir, id, update_params) {
2993                    Ok(result) => {
2994                        let unit_value =
2995                            serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2996                        let summary = unit_delta_label(&unit_value)
2997                            .map(|label| format!("mana delta: decision resolved on {label}"))
2998                            .unwrap_or_else(|| format!("mana delta: decision resolved on {id}"));
2999                        set_mana_delta_widget(
3000                            &ctx,
3001                            summary.clone(),
3002                            Some("decision resolved".into()),
3003                        )
3004                        .await;
3005                        Ok(text_output(
3006                            summary,
3007                            json!({
3008                                "action": "decision_resolve",
3009                                "id": id,
3010                                "resolve_decisions": params["resolve_decisions"],
3011                                "unit": unit_value,
3012                                "path": result.path,
3013                            }),
3014                        ))
3015                    }
3016                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3017                }
3018            }
3019            "reopen" => {
3020                let id = params["id"]
3021                    .as_str()
3022                    .ok_or_else(|| crate::error::Error::Tool("reopen requires 'id'".into()))?;
3023                match mana_core::api::reopen_unit(&mana_dir, id) {
3024                    Ok(result) => {
3025                        let summary = format!(
3026                            "mana delta: reopened {} ({})",
3027                            result.unit.id, result.unit.title
3028                        );
3029                        set_mana_delta_widget(&ctx, summary, Some("status=open".into())).await;
3030                        Ok(text_output(
3031                            format!("Reopened unit {} ({})", result.unit.id, result.unit.title),
3032                            json!({
3033                                "action": "reopen",
3034                                "unit": {
3035                                    "id": result.unit.id,
3036                                    "title": result.unit.title,
3037                                    "status": result.unit.status,
3038                                },
3039                                "path": result.path,
3040                            }),
3041                        ))
3042                    }
3043                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3044                }
3045            }
3046            "verify" => {
3047                let id = params["id"]
3048                    .as_str()
3049                    .ok_or_else(|| crate::error::Error::Tool("verify requires 'id'".into()))?;
3050                match mana_core::api::run_verify(&mana_dir, id) {
3051                    Ok(Some(result)) => Ok(text_output(
3052                        format!(
3053                            "Verify {} for unit {id}{}",
3054                            if result.passed { "passed" } else { "failed" },
3055                            result
3056                                .exit_code
3057                                .map(|code| format!(" (exit {code})"))
3058                                .unwrap_or_default()
3059                        ),
3060                        json!({
3061                            "passed": result.passed,
3062                            "exit_code": result.exit_code,
3063                            "stdout": result.stdout,
3064                            "stderr": result.stderr,
3065                            "timed_out": result.timed_out,
3066                            "command": result.command,
3067                            "timeout_secs": result.timeout_secs,
3068                            "unit_id": id,
3069                        }),
3070                    )),
3071                    Ok(None) => Ok(ToolOutput::text(format!(
3072                        "Unit {id} has no verify command."
3073                    ))),
3074                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3075                }
3076            }
3077            "fail" => {
3078                let id = params["id"]
3079                    .as_str()
3080                    .ok_or_else(|| crate::error::Error::Tool("fail requires 'id'".into()))?;
3081                match mana_core::api::fail_unit(
3082                    &mana_dir,
3083                    id,
3084                    parse_optional_string(&params["reason"]),
3085                ) {
3086                    Ok(unit) => {
3087                        let unit_value =
3088                            serde_json::to_value(&unit).unwrap_or(serde_json::Value::Null);
3089                        let summary = unit_delta_label(&unit_value)
3090                            .map(|label| format!("mana delta: marked failed {label}"))
3091                            .unwrap_or_else(|| format!("mana delta: marked failed {id}"));
3092                        set_mana_delta_widget(
3093                            &ctx,
3094                            summary,
3095                            params["reason"].as_str().map(|s| s.to_string()),
3096                        )
3097                        .await;
3098                        Ok(text_output(
3099                            format!("Marked unit {id} as failed"),
3100                            json!({
3101                                "action": "fail",
3102                                "id": id,
3103                                "reason": params["reason"],
3104                                "unit": unit_value,
3105                            }),
3106                        ))
3107                    }
3108                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3109                }
3110            }
3111            "delete" => {
3112                let id = params["id"]
3113                    .as_str()
3114                    .ok_or_else(|| crate::error::Error::Tool("delete requires 'id'".into()))?;
3115                match mana_core::api::delete_unit(&mana_dir, id) {
3116                    Ok(result) => {
3117                        let summary =
3118                            format!("mana delta: deleted {} ({})", result.id, result.title);
3119                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
3120                        Ok(text_output(
3121                            format!("Deleted unit {} ({})", result.id, result.title),
3122                            json!({ "action": "delete", "id": result.id, "title": result.title }),
3123                        ))
3124                    }
3125                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3126                }
3127            }
3128            "dep_add" => {
3129                let from_id = params["from_id"].as_str().ok_or_else(|| {
3130                    crate::error::Error::Tool("dep_add requires 'from_id'".into())
3131                })?;
3132                let dep_id = params["dep_id"]
3133                    .as_str()
3134                    .ok_or_else(|| crate::error::Error::Tool("dep_add requires 'dep_id'".into()))?;
3135                match mana_core::api::add_dep(&mana_dir, from_id, dep_id) {
3136                    Ok(result) => {
3137                        let summary = format!(
3138                            "mana delta: dependency added {} -> {}",
3139                            result.from_id, result.to_id
3140                        );
3141                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
3142                        Ok(text_output(
3143                            format!(
3144                                "Added dependency: {} depends on {}",
3145                                result.from_id, result.to_id
3146                            ),
3147                            json!({ "action": "dep_add", "from_id": result.from_id, "dep_id": result.to_id }),
3148                        ))
3149                    }
3150                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3151                }
3152            }
3153            "dep_remove" => {
3154                let from_id = params["from_id"].as_str().ok_or_else(|| {
3155                    crate::error::Error::Tool("dep_remove requires 'from_id'".into())
3156                })?;
3157                let dep_id = params["dep_id"].as_str().ok_or_else(|| {
3158                    crate::error::Error::Tool("dep_remove requires 'dep_id'".into())
3159                })?;
3160                match mana_core::api::remove_dep(&mana_dir, from_id, dep_id) {
3161                    Ok(result) => {
3162                        let summary = format!(
3163                            "mana delta: dependency removed {} -> {}",
3164                            result.from_id, result.to_id
3165                        );
3166                        set_mana_delta_widget(&ctx, summary.clone(), None).await;
3167                        Ok(text_output(
3168                            format!(
3169                                "Removed dependency: {} no longer depends on {}",
3170                                result.from_id, result.to_id
3171                            ),
3172                            json!({ "action": "dep_remove", "from_id": result.from_id, "dep_id": result.to_id }),
3173                        ))
3174                    }
3175                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3176                }
3177            }
3178            "fact_create" => {
3179                let title = parse_optional_string(&params["title"])
3180                    .or_else(|| parse_optional_string(&params["fact_title"]))
3181                    .ok_or_else(|| {
3182                        crate::error::Error::Tool("fact_create requires 'title'".into())
3183                    })?;
3184                let verify = parse_optional_string(&params["verify"]).ok_or_else(|| {
3185                    crate::error::Error::Tool("fact_create requires 'verify'".into())
3186                })?;
3187                // Transitional compatibility: runtime still accepts legacy `paths_csv`, but
3188                // the model-facing schema advertises only canonical `paths`.
3189                let fact_paths = parse_optional_string(&params["paths_csv"]).or_else(|| {
3190                    let paths = parse_csv_strings(&params["paths"], "paths").ok()?;
3191                    if paths.is_empty() {
3192                        None
3193                    } else {
3194                        Some(paths.join(","))
3195                    }
3196                });
3197                let fact_params = mana_core::ops::fact::FactParams {
3198                    title,
3199                    verify,
3200                    description: parse_optional_string(&params["description"]),
3201                    paths: fact_paths,
3202                    ttl_days: params["ttl_days"].as_i64(),
3203                    pass_ok: params["pass_ok"].as_bool().unwrap_or(true),
3204                };
3205                match mana_core::api::create_fact(&mana_dir, fact_params) {
3206                    Ok(result) => {
3207                        let summary = format!(
3208                            "mana delta: created fact {} ({})",
3209                            result.unit_id, result.unit.title
3210                        );
3211                        set_mana_delta_widget(&ctx, summary.clone(), Some("fact".into())).await;
3212                        Ok(text_output(
3213                            format!("Created fact {} ({})", result.unit_id, result.unit.title),
3214                            json!({
3215                                "action": "fact_create",
3216                                "unit_id": result.unit_id,
3217                                "unit": {
3218                                    "id": result.unit.id,
3219                                    "title": result.unit.title,
3220                                    "unit_type": result.unit.unit_type,
3221                                    "verify": result.unit.verify,
3222                                    "paths": result.unit.paths,
3223                                    "stale_after": result.unit.stale_after,
3224                                }
3225                            }),
3226                        ))
3227                    }
3228                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3229                }
3230            }
3231            "fact_verify" => match mana_core::api::verify_facts(&mana_dir) {
3232                Ok(result) => Ok(text_output(
3233                    format!(
3234                        "Verified {}/{} facts · {} stale · {} failing · {} suspect",
3235                        result.verified_count,
3236                        result.total_facts,
3237                        result.stale_count,
3238                        result.failing_count,
3239                        result.suspect_count
3240                    ),
3241                    json!({
3242                        "total_facts": result.total_facts,
3243                        "verified_count": result.verified_count,
3244                        "stale_count": result.stale_count,
3245                        "failing_count": result.failing_count,
3246                        "suspect_count": result.suspect_count,
3247                    }),
3248                )),
3249                Err(e) => Ok(ToolOutput::error(e.to_string())),
3250            },
3251            "logs" => {
3252                if let Some(run_id) = params["run_id"].as_str() {
3253                    if let Some(state) = run_state_snapshot(&self.run_store, Some(run_id)) {
3254                        let text = if state.log_lines.is_empty() {
3255                            format!(
3256                                "No native stream events captured yet for run {}.",
3257                                state.run_id
3258                            )
3259                        } else {
3260                            truncate_with_note(&state.log_lines.join("\n"))
3261                        };
3262                        return Ok(text_output(
3263                            text,
3264                            serde_json::to_value(&state).unwrap_or(serde_json::Value::Null),
3265                        ));
3266                    }
3267                    return Ok(ToolOutput::error(format!(
3268                        "Unknown native mana run_id: {run_id}"
3269                    )));
3270                }
3271
3272                let id = params["id"].as_str().ok_or_else(|| {
3273                    crate::error::Error::Tool("logs requires 'id' or 'run_id'".into())
3274                })?;
3275                match find_all_logs(id) {
3276                    Ok(paths) if paths.is_empty() => Ok(ToolOutput::text(format!(
3277                        "No logs for unit {id}. Has it been dispatched with mana run?"
3278                    ))),
3279                    Ok(paths) => {
3280                        let mut sections = Vec::new();
3281                        for path in &paths {
3282                            let filename = path
3283                                .file_name()
3284                                .and_then(|n| n.to_str())
3285                                .unwrap_or("unknown");
3286                            let body = std::fs::read_to_string(path).unwrap_or_else(|e| {
3287                                format!("(error reading {}: {e})", path.display())
3288                            });
3289                            sections.push(format!("═══ {filename} ═══\n\n{body}"));
3290                        }
3291                        let text = truncate_with_note(&sections.join("\n\n"));
3292                        Ok(text_output(text, json!({ "unit_id": id, "logs": paths })))
3293                    }
3294                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3295                }
3296            }
3297            "agents" => match load_agents() {
3298                Ok(agents) => Ok(json_output(&agents)),
3299                Err(e) => Ok(ToolOutput::error(e.to_string())),
3300            },
3301            "reparent" => {
3302                let id = params["id"]
3303                    .as_str()
3304                    .ok_or_else(|| crate::error::Error::Tool("reparent requires 'id'".into()))?;
3305                let parent = parse_optional_string(&params["parent"]);
3306                let reason = parse_optional_string(&params["reason"])
3307                    .or_else(|| parse_optional_string(&params["parent_reason"]));
3308                let unit_path = mana_core::discovery::find_unit_file(&mana_dir, id)
3309                    .map_err(|error| crate::error::Error::Tool(error.to_string()))?;
3310                let mut unit = mana_core::unit::Unit::from_file(&unit_path)
3311                    .map_err(|error| crate::error::Error::Tool(error.to_string()))?;
3312                let old_parent = unit.parent.clone();
3313                unit.parent = parent.clone();
3314                unit.updated_at = chrono::Utc::now();
3315                unit.to_file(&unit_path)
3316                    .map_err(|error| crate::error::Error::Tool(error.to_string()))?;
3317                let summary = format!(
3318                    "mana delta: reparented {id} from {} to {}",
3319                    old_parent.as_deref().unwrap_or("<root>"),
3320                    parent.as_deref().unwrap_or("<root>")
3321                );
3322                set_mana_delta_widget(&ctx, summary.clone(), reason.clone()).await;
3323                Ok(text_output(
3324                    summary,
3325                    json!({
3326                        "action": "reparent",
3327                        "id": id,
3328                        "old_parent": old_parent,
3329                        "new_parent": parent,
3330                        "reason": reason,
3331                    }),
3332                ))
3333            }
3334            "run_state" | "evaluate" => {
3335                let run_id = params["run_id"].as_str();
3336                match run_state_snapshot(&self.run_store, run_id) {
3337                    Some(state) => {
3338                        if action == "evaluate" {
3339                            Ok(evaluate_run_output(&state))
3340                        } else {
3341                            Ok(run_state_output(&state))
3342                        }
3343                    }
3344                    None => {
3345                        let which = run_id.unwrap_or("latest");
3346                        Ok(ToolOutput::error(format!(
3347                            "No native mana run state available for {which}. Start one with mana(action=\"run\")."
3348                        )))
3349                    }
3350                }
3351            }
3352            "next" => {
3353                let count = params["count"].as_u64().unwrap_or(1).max(1) as usize;
3354                match mana_core::api::load_index(&mana_dir) {
3355                    Ok(index) => {
3356                        let ready: Vec<&mana_core::index::IndexEntry> = index
3357                            .units
3358                            .iter()
3359                            .filter(|e| {
3360                                e.status == mana_core::unit::Status::Open
3361                                    && e.has_verify
3362                                    && !e.feature
3363                                    && mana_core::blocking::check_blocked(e, &index).is_none()
3364                                    && !index.units.iter().any(|child| {
3365                                        child.parent.as_deref() == Some(e.id.as_str())
3366                                            && child.status != mana_core::unit::Status::Closed
3367                                    })
3368                            })
3369                            .collect();
3370
3371                        let mut reverse_deps: std::collections::HashMap<String, Vec<String>> =
3372                            std::collections::HashMap::new();
3373                        for entry in &index.units {
3374                            for dep_id in &entry.dependencies {
3375                                reverse_deps
3376                                    .entry(dep_id.clone())
3377                                    .or_default()
3378                                    .push(entry.id.clone());
3379                            }
3380                        }
3381
3382                        fn count_transitive_unblocks(
3383                            unit_id: &str,
3384                            reverse_deps: &std::collections::HashMap<String, Vec<String>>,
3385                        ) -> usize {
3386                            let mut visited = std::collections::HashSet::new();
3387                            let mut stack = vec![unit_id.to_string()];
3388                            while let Some(current) = stack.pop() {
3389                                if let Some(dependents) = reverse_deps.get(&current) {
3390                                    for dep in dependents {
3391                                        if visited.insert(dep.clone()) {
3392                                            stack.push(dep.clone());
3393                                        }
3394                                    }
3395                                }
3396                            }
3397                            visited.len()
3398                        }
3399
3400                        fn score_unit(
3401                            entry: &mana_core::index::IndexEntry,
3402                            unblock_count: usize,
3403                        ) -> f64 {
3404                            let priority_score = (5u8.saturating_sub(entry.priority)) as f64 * 10.0;
3405                            let unblock_score = (unblock_count as f64 * 5.0).min(50.0);
3406                            let age_days = std::time::SystemTime::now()
3407                                .duration_since(std::time::UNIX_EPOCH)
3408                                .unwrap_or_default()
3409                                .as_secs()
3410                                / 86_400;
3411                            let created_days = entry.created_at.timestamp().max(0) as u64 / 86_400;
3412                            let age_days = age_days.saturating_sub(created_days) as f64;
3413                            let age_score = age_days.min(30.0);
3414                            let attempt_penalty = (entry.attempts as f64 * 3.0).min(15.0);
3415                            priority_score + unblock_score + age_score - attempt_penalty
3416                        }
3417
3418                        let mut scored: Vec<ScoredUnit> = ready
3419                            .iter()
3420                            .map(|entry| {
3421                                let transitive_count =
3422                                    count_transitive_unblocks(&entry.id, &reverse_deps);
3423                                let unblocks =
3424                                    reverse_deps.get(&entry.id).cloned().unwrap_or_default();
3425                                let score = score_unit(entry, transitive_count);
3426                                let now_days = std::time::SystemTime::now()
3427                                    .duration_since(std::time::UNIX_EPOCH)
3428                                    .unwrap_or_default()
3429                                    .as_secs()
3430                                    / 86_400;
3431                                let created_days =
3432                                    entry.created_at.timestamp().max(0) as u64 / 86_400;
3433                                let age_days = now_days.saturating_sub(created_days);
3434                                ScoredUnit {
3435                                    id: entry.id.clone(),
3436                                    title: entry.title.clone(),
3437                                    priority: entry.priority,
3438                                    score,
3439                                    unblocks,
3440                                    age_days,
3441                                    attempts: entry.attempts,
3442                                }
3443                            })
3444                            .collect();
3445
3446                        scored.sort_by(|a, b| {
3447                            b.score
3448                                .partial_cmp(&a.score)
3449                                .unwrap_or(std::cmp::Ordering::Equal)
3450                        });
3451                        scored.truncate(count);
3452                        Ok(text_output(
3453                            scored_units_to_text(&scored),
3454                            serde_json::to_value(&scored).unwrap_or(serde_json::Value::Null),
3455                        ))
3456                    }
3457                    Err(e) => Ok(ToolOutput::error(e.to_string())),
3458                }
3459            }
3460            "tree" => {
3461                let id = params["id"].as_str();
3462                let lines = if let Some(root_id) = id {
3463                    match mana_core::api::get_tree(&mana_dir, root_id) {
3464                        Ok(tree) => {
3465                            let mut lines = Vec::new();
3466                            tree_lines(&tree, 0, &mut lines);
3467                            lines
3468                        }
3469                        Err(tree_err) => match mana_core::ops::show::get(&mana_dir, root_id) {
3470                            Ok(result) if result.unit.is_archived => {
3471                                return Ok(ToolOutput::error(format!(
3472                                    "Archived unit {root_id} can be shown but not rendered in tree view. Tree only includes active units."
3473                                )));
3474                            }
3475                            Ok(_) | Err(_) => return Ok(ToolOutput::error(tree_err.to_string())),
3476                        },
3477                    }
3478                } else {
3479                    match mana_core::api::load_index(&mana_dir) {
3480                        Ok(index) => {
3481                            let roots: Vec<_> = index
3482                                .units
3483                                .iter()
3484                                .filter(|entry| entry.parent.is_none())
3485                                .map(|entry| entry.id.clone())
3486                                .collect();
3487                            let mut lines = Vec::new();
3488                            for (idx, root_id) in roots.iter().enumerate() {
3489                                match mana_core::api::get_tree(&mana_dir, root_id) {
3490                                    Ok(tree) => {
3491                                        if idx > 0 {
3492                                            lines.push(String::new());
3493                                        }
3494                                        tree_lines(&tree, 0, &mut lines);
3495                                    }
3496                                    Err(e) => return Ok(ToolOutput::error(e.to_string())),
3497                                }
3498                            }
3499                            lines
3500                        }
3501                        Err(e) => return Ok(ToolOutput::error(e.to_string())),
3502                    }
3503                };
3504                let text = if lines.is_empty() {
3505                    "(no units)".to_string()
3506                } else {
3507                    truncate_with_note(&lines.join("\n"))
3508                };
3509                Ok(text_output(text, json!({ "root": id })))
3510            }
3511            "run" => {
3512                let requested_jobs = params["jobs"].as_u64().unwrap_or(4) as u32;
3513                let run_params = NativeRunParams {
3514                    target: target_from_params(&params)?,
3515                    jobs: requested_jobs.max(1),
3516                    dry_run: params["dry_run"].as_bool().unwrap_or(false),
3517                    loop_mode: params["loop"].as_bool().unwrap_or(false),
3518                    keep_going: params["keep_going"].as_bool().unwrap_or(false),
3519                    timeout: params["timeout"].as_u64().unwrap_or(30) as u32,
3520                    idle_timeout: params["idle_timeout"].as_u64().unwrap_or(5) as u32,
3521                    json_stream: true,
3522                    review: params["review"].as_bool().unwrap_or(false),
3523                    run_model: None,
3524                    run_thinking: None,
3525                };
3526                let target_ids = target_ids_from_run_target(&run_params.target);
3527                if !target_ids.is_empty() {
3528                    if let Some(guardrail) = retry_guardrail_for_targets(&mana_dir, &target_ids)? {
3529                        return Ok(ToolOutput {
3530                            content: vec![imp_llm::ContentBlock::Text {
3531                                text: "mana run blocked: failed units must be updated before retry"
3532                                    .to_string(),
3533                            }],
3534                            details: guardrail,
3535                            is_error: true,
3536                        });
3537                    }
3538                }
3539                let scope = scope_from_target(&run_params.target);
3540                let run_id = {
3541                    let mut store = self.run_store.lock().map_err(|_| {
3542                        crate::error::Error::Tool("mana run state lock poisoned".into())
3543                    })?;
3544                    let run_id = store.start_run(scope.clone(), &run_params);
3545                    store.persist();
3546                    run_id
3547                };
3548
3549                let started = run_started_output(&scope, &run_id, &run_params);
3550                spawn_background_run(
3551                    mana_dir.clone(),
3552                    run_params,
3553                    ctx,
3554                    self.run_store.clone(),
3555                    run_id,
3556                );
3557                return Ok(started);
3558            }
3559            other => Ok(ToolOutput::error(format!(
3560                "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"
3561            ))),
3562        }
3563    }
3564}
3565
3566#[cfg(test)]
3567mod tests {
3568    use std::sync::Arc;
3569
3570    use async_trait::async_trait;
3571    use serde_json::json;
3572    use tokio::sync::mpsc;
3573
3574    use super::{
3575        compact_list_output, evaluate_run_output, mana_close_error_output,
3576        mana_close_force_reason_error, mana_guide_output, mana_run_core, mana_template_output,
3577        parent_placement_details, parse_guide_topic, parse_template_kind,
3578        retry_guardrail_for_targets, run_state_output, runtime_info_for_run, stream_event_line,
3579        target_ids_from_run_target, unix_time_ms, validate_mana_action, validate_native_run_target,
3580        worker_options_for_native_unit, GuideTopic, ManaRunStore, ManaTool, NativeRunState,
3581        RunTarget, RunUnitStatus, TemplateKind, INTERRUPTED_RUN_STALE_MS,
3582    };
3583    use crate::tools::{FileCache, FileTracker, Tool, ToolContext, ToolUpdate};
3584    use crate::ui::{NotifyLevel, NullInterface, WidgetContent};
3585
3586    enum ManaResult {
3587        ModeBlocked(String),
3588        Attempted(crate::tools::ToolOutput),
3589    }
3590
3591    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3592
3593    type TestWidgets = Arc<std::sync::Mutex<Vec<(String, Option<WidgetContent>)>>>;
3594
3595    struct TestUi {
3596        widgets: TestWidgets,
3597    }
3598
3599    #[async_trait]
3600    impl crate::ui::UserInterface for TestUi {
3601        fn has_ui(&self) -> bool {
3602            true
3603        }
3604
3605        async fn notify(&self, _message: &str, _level: NotifyLevel) {}
3606
3607        async fn confirm(&self, _title: &str, _message: &str) -> Option<bool> {
3608            None
3609        }
3610
3611        async fn select_with_context(
3612            &self,
3613            _title: &str,
3614            _context: &str,
3615            _options: &[crate::ui::SelectOption],
3616        ) -> Option<usize> {
3617            None
3618        }
3619
3620        async fn input_with_context(
3621            &self,
3622            _title: &str,
3623            _context: &str,
3624            _placeholder: &str,
3625        ) -> Option<String> {
3626            None
3627        }
3628
3629        async fn set_status(&self, _key: &str, _text: Option<&str>) {}
3630
3631        async fn set_widget(&self, key: &str, content: Option<WidgetContent>) {
3632            self.widgets
3633                .lock()
3634                .unwrap()
3635                .push((key.to_string(), content));
3636        }
3637
3638        async fn custom(&self, _component: crate::ui::ComponentSpec) -> Option<serde_json::Value> {
3639            None
3640        }
3641    }
3642
3643    async fn run_with_mode(mode_name: &str, action: &str) -> ManaResult {
3644        let prev = {
3645            let _guard = ENV_LOCK.lock().unwrap();
3646            let prev = std::env::var("IMP_MODE").ok();
3647            std::env::set_var("IMP_MODE", mode_name);
3648            prev
3649        };
3650
3651        let dir = tempfile::tempdir().unwrap();
3652        let mana_dir = dir.path().join(".mana");
3653        std::fs::create_dir_all(&mana_dir).unwrap();
3654        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3655        std::fs::write(
3656            mana_dir.join("1-test-unit.md"),
3657            "---\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",
3658        )
3659        .unwrap();
3660        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3661        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3662        let ctx = ToolContext {
3663            cwd: dir.path().to_path_buf(),
3664            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3665            update_tx: tx,
3666            command_tx: cmd_tx,
3667            ui: Arc::new(NullInterface),
3668            file_cache: Arc::new(FileCache::new()),
3669            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3670            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3671            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3672            lua_tool_loader: None,
3673            mode: crate::config::AgentMode::from_name(mode_name)
3674                .unwrap_or(crate::config::AgentMode::Full),
3675            read_max_lines: 500,
3676            turn_mana_review: Arc::new(std::sync::Mutex::new(
3677                crate::mana_review::TurnManaReviewAccumulator::default(),
3678            )),
3679            config: Arc::new(crate::config::Config::default()),
3680            run_policy: Default::default(),
3681            supporting_provenance: Vec::new(),
3682        };
3683
3684        let tool = ManaTool::default();
3685        let outcome = tool
3686            .execute("call_1", json!({ "action": action, "id": "1" }), ctx)
3687            .await;
3688
3689        match prev {
3690            Some(v) => {
3691                let _guard = ENV_LOCK.lock().unwrap();
3692                std::env::set_var("IMP_MODE", v)
3693            }
3694            None => {
3695                let _guard = ENV_LOCK.lock().unwrap();
3696                std::env::remove_var("IMP_MODE")
3697            }
3698        }
3699
3700        match outcome {
3701            Err(crate::error::Error::Tool(msg)) => {
3702                ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
3703            }
3704            Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
3705            Ok(output) => {
3706                if output.is_error {
3707                    if let Some(text) = output.text_content() {
3708                        if text.contains("mode") && text.contains(action) {
3709                            return ManaResult::ModeBlocked(text.to_string());
3710                        }
3711                    }
3712                }
3713                ManaResult::Attempted(output)
3714            }
3715        }
3716    }
3717
3718    fn ctx_with_mode(
3719        dir: &std::path::Path,
3720        mode: crate::config::AgentMode,
3721    ) -> (ToolContext, tempfile::TempDir) {
3722        let mana_dir = dir.join(".mana");
3723        std::fs::create_dir_all(&mana_dir).unwrap();
3724        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3725        std::fs::write(
3726            mana_dir.join("1-test-unit.md"),
3727            "---\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",
3728        )
3729        .unwrap();
3730        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3731        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3732        let ctx = ToolContext {
3733            cwd: dir.to_path_buf(),
3734            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3735            update_tx: tx,
3736            command_tx: cmd_tx,
3737            ui: Arc::new(NullInterface),
3738            file_cache: Arc::new(FileCache::new()),
3739            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3740            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3741            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3742            lua_tool_loader: None,
3743            mode,
3744            read_max_lines: 500,
3745            turn_mana_review: Arc::new(std::sync::Mutex::new(
3746                crate::mana_review::TurnManaReviewAccumulator::default(),
3747            )),
3748            config: Arc::new(crate::config::Config::default()),
3749            run_policy: Default::default(),
3750            supporting_provenance: Vec::new(),
3751        };
3752        (ctx, tempfile::tempdir().unwrap())
3753    }
3754
3755    fn ctx_with_ui(
3756        dir: &std::path::Path,
3757        mode: crate::config::AgentMode,
3758    ) -> (ToolContext, tempfile::TempDir, TestWidgets) {
3759        let mana_dir = dir.join(".mana");
3760        std::fs::create_dir_all(&mana_dir).unwrap();
3761        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3762        std::fs::write(
3763            mana_dir.join("1-test-unit.md"),
3764            "---\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",
3765        )
3766        .unwrap();
3767        let widgets = Arc::new(std::sync::Mutex::new(Vec::new()));
3768        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3769        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3770        let ctx = ToolContext {
3771            cwd: dir.to_path_buf(),
3772            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3773            update_tx: tx,
3774            command_tx: cmd_tx,
3775            ui: Arc::new(TestUi {
3776                widgets: widgets.clone(),
3777            }),
3778            file_cache: Arc::new(FileCache::new()),
3779            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3780            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3781            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3782            lua_tool_loader: None,
3783            mode,
3784            read_max_lines: 500,
3785            turn_mana_review: Arc::new(std::sync::Mutex::new(
3786                crate::mana_review::TurnManaReviewAccumulator::default(),
3787            )),
3788            config: Arc::new(crate::config::Config::default()),
3789            run_policy: Default::default(),
3790            supporting_provenance: Vec::new(),
3791        };
3792        (ctx, tempfile::tempdir().unwrap(), widgets)
3793    }
3794
3795    async fn run_with_ctx_mode(mode: crate::config::AgentMode, action: &str) -> ManaResult {
3796        let dir = tempfile::tempdir().unwrap();
3797        let (ctx, _keep) = ctx_with_mode(dir.path(), mode);
3798        let tool = ManaTool::default();
3799        let outcome = tool
3800            .execute("call_ctx", json!({ "action": action, "id": "1" }), ctx)
3801            .await;
3802        match outcome {
3803            Err(crate::error::Error::Tool(msg)) => {
3804                ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
3805            }
3806            Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
3807            Ok(output) => {
3808                if output.is_error {
3809                    if let Some(text) = output.text_content() {
3810                        if text.contains("mode") && text.contains(action) {
3811                            return ManaResult::ModeBlocked(text.to_string());
3812                        }
3813                    }
3814                }
3815                ManaResult::Attempted(output)
3816            }
3817        }
3818    }
3819
3820    #[tokio::test]
3821    async fn create_sets_mana_delta_widget_and_action_details() {
3822        let dir = tempfile::tempdir().unwrap();
3823        let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
3824        let tool = ManaTool::default();
3825        let result = tool
3826            .execute(
3827                "call_create_widget",
3828                json!({ "action": "create", "title": "Widget unit", "verify": "test -n ok" }),
3829                ctx,
3830            )
3831            .await
3832            .unwrap();
3833
3834        assert_eq!(result.details["action"], "create");
3835        assert_eq!(result.details["unit"]["title"], "Widget unit");
3836        let widgets = widgets.lock().unwrap();
3837        assert!(widgets.iter().any(|(key, content)| {
3838            key == "mana"
3839                && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: created 2 · Widget unit")))
3840        }));
3841    }
3842
3843    #[tokio::test]
3844    async fn decision_add_sets_mana_delta_widget_and_action_details() {
3845        let dir = tempfile::tempdir().unwrap();
3846        let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
3847        let tool = ManaTool::default();
3848        let result = tool
3849            .execute(
3850                "call_decision_widget",
3851                json!({ "action": "decision_add", "id": "1", "description": "Choose retry limit" }),
3852                ctx,
3853            )
3854            .await
3855            .unwrap();
3856
3857        assert_eq!(result.details["action"], "decision_add");
3858        assert_eq!(result.details["unit"]["decisions"][0], "Choose retry limit");
3859        let widgets = widgets.lock().unwrap();
3860        assert!(widgets.iter().any(|(key, content)| {
3861            key == "mana"
3862                && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: decision added on 1 · Test unit")))
3863        }));
3864    }
3865
3866    #[tokio::test]
3867    async fn worker_blocks_create() {
3868        match run_with_mode("worker", "create").await {
3869            ManaResult::ModeBlocked(_) => {}
3870            ManaResult::Attempted(out) => {
3871                panic!(
3872                    "worker should block 'create', got: {:?}",
3873                    out.text_content()
3874                )
3875            }
3876        }
3877    }
3878
3879    #[tokio::test]
3880    async fn create_supports_rich_unit_fields() {
3881        let dir = tempfile::tempdir().unwrap();
3882        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3883        let tool = ManaTool::default();
3884        let result = tool
3885            .execute(
3886                "call_create_rich",
3887                json!({
3888                    "action": "create",
3889                    "title": "Rich unit",
3890                    "description": "Implement the thing",
3891                    "acceptance": "- works\n- tested",
3892                    "notes": "start here",
3893                    "design": "follow existing pattern",
3894                    "verify": "test -n ok",
3895                    "labels": ["feature", "backend"],
3896                    "deps": ["1"],
3897                    "paths": ["src/lib.rs", "src/auth.rs"],
3898                    "requires": ["auth-api"],
3899                    "produces": ["auth-fix"],
3900                    "decisions": ["Confirm whether auth should stay sync"],
3901                    "feature": true,
3902                    "fail_first": true,
3903                    "verify_timeout": 12,
3904                    "force": false
3905                }),
3906                ctx,
3907            )
3908            .await
3909            .unwrap();
3910        let unit = &result.details["unit"];
3911        assert_eq!(unit["acceptance"], "- works\n- tested");
3912        assert_eq!(unit["labels"][0], "feature");
3913        assert_eq!(unit["dependencies"][0], "1");
3914        assert_eq!(unit["paths"][0], "src/lib.rs");
3915        assert_eq!(unit["requires"][0], "auth-api");
3916        assert_eq!(unit["produces"][0], "auth-fix");
3917        assert_eq!(
3918            unit["decisions"][0],
3919            "Confirm whether auth should stay sync"
3920        );
3921        assert_eq!(unit["feature"], true);
3922        assert_eq!(unit["fail_first"], true);
3923        assert_eq!(unit["verify_timeout"], 12);
3924    }
3925
3926    #[test]
3927    fn parent_placement_details_warns_when_parent_reason_missing() {
3928        let details = parent_placement_details(Some("304"), None);
3929
3930        assert_eq!(details["parent"], json!("304"));
3931        assert_eq!(details["warning"], json!("parent_reason_missing"));
3932        assert!(details["hint"]
3933            .as_str()
3934            .unwrap_or_default()
3935            .contains("confirm it belongs"));
3936    }
3937
3938    #[test]
3939    fn parent_placement_details_accepts_explicit_reason() {
3940        let details = parent_placement_details(
3941            Some("313"),
3942            Some("This is workflow reliability work, not tool schema audit."),
3943        );
3944
3945        assert_eq!(details["warning"], serde_json::Value::Null);
3946        assert_eq!(
3947            details["parent_reason"],
3948            json!("This is workflow reliability work, not tool schema audit.")
3949        );
3950    }
3951
3952    #[test]
3953    fn close_force_requires_reason_with_evidence() {
3954        let output = mana_close_force_reason_error("313.2");
3955
3956        assert!(output.is_error);
3957        assert_eq!(output.details["action"], json!("close"));
3958        assert_eq!(output.details["missing"], json!(["reason"]));
3959        assert!(output
3960            .text_content()
3961            .unwrap_or_default()
3962            .contains("equivalent verify evidence"));
3963    }
3964
3965    #[test]
3966    fn close_verify_errors_include_recovery_hint() {
3967        let output = mana_close_error_output(
3968            "313.2",
3969            "Verify command failed during close: cargo test -p imp-core one two --no-run"
3970                .to_string(),
3971        );
3972
3973        assert!(output.is_error);
3974        assert_eq!(output.details["verify_related"], json!(true));
3975        assert_eq!(output.details["force_requires_reason"], json!(true));
3976        let hint = output.details["recovery_hint"].as_str().unwrap_or_default();
3977        assert!(hint.contains("equivalent focused checks"));
3978    }
3979
3980    #[test]
3981    fn close_non_verify_errors_stay_plain() {
3982        let output = mana_close_error_output("313.2", "Unit not found".to_string());
3983
3984        assert!(output.is_error);
3985        assert_eq!(output.details["verify_related"], json!(false));
3986        assert!(output.details["recovery_hint"].is_null());
3987        assert_eq!(output.text_content().unwrap_or_default(), "Unit not found");
3988    }
3989
3990    #[test]
3991    fn mana_guide_outputs_concise_structured_topic() {
3992        let output = mana_guide_output(GuideTopic::Verify);
3993
3994        assert!(!output.is_error);
3995        assert_eq!(output.details["action"], json!("guide"));
3996        assert_eq!(output.details["topic"], json!("verify"));
3997        assert!(output.details["guidance"].as_array().unwrap().len() <= 3);
3998        assert!(output
3999            .text_content()
4000            .unwrap_or_default()
4001            .contains("mana guide: verify"));
4002    }
4003
4004    #[test]
4005    fn mana_overview_guide_prefers_update_before_create() {
4006        let output = mana_guide_output(GuideTopic::Overview);
4007        let text = output.text_content().unwrap_or_default();
4008
4009        assert!(text.contains("Before creating"));
4010        assert!(text.contains("update"));
4011        assert!(text.contains("notes"));
4012    }
4013
4014    #[test]
4015    fn compact_list_output_caps_and_guides_toward_existing_units() {
4016        let now = chrono::Utc::now();
4017        let entries: Vec<mana_core::index::IndexEntry> = (0..60)
4018            .map(|i| mana_core::index::IndexEntry {
4019                id: format!("330.{i}"),
4020                title: format!("Task {i}"),
4021                handle: None,
4022                status: mana_core::unit::Status::Open,
4023                priority: 1,
4024                parent: Some("330".to_string()),
4025                dependencies: Vec::new(),
4026                labels: vec!["mana".to_string()],
4027                assignee: None,
4028                updated_at: now,
4029                produces: Vec::new(),
4030                requires: Vec::new(),
4031                has_verify: true,
4032                verify: Some("cargo test".to_string()),
4033                created_at: now,
4034                claimed_by: None,
4035                attempts: 0,
4036                paths: vec!["crates/imp-core/src/tools/mana.rs".to_string()],
4037                kind: mana_core::unit::UnitType::Task,
4038                feature: false,
4039                has_decisions: false,
4040            })
4041            .collect();
4042
4043        let output = compact_list_output(&entries, Some(100));
4044        let text = output.text_content().unwrap_or_default();
4045
4046        assert!(!output.is_error);
4047        assert_eq!(output.details["total"], json!(60));
4048        assert_eq!(output.details["shown"], json!(50));
4049        assert_eq!(output.details["limit"], json!(50));
4050        assert_eq!(output.details["truncated"], json!(true));
4051        assert_eq!(output.details["units"].as_array().unwrap().len(), 50);
4052        assert!(text.contains("Prefer `show` + `update`/`notes_append`"));
4053        assert!(text.contains("10 more omitted"));
4054        assert!(!text.contains("verify\":\"cargo test"));
4055    }
4056
4057    #[test]
4058    fn create_validation_hint_prefers_existing_unit_when_title_missing() {
4059        let output = validate_mana_action("create", &json!({}))
4060            .expect("create without title should fail validation");
4061
4062        assert!(output.is_error);
4063        assert!(output
4064            .text_content()
4065            .unwrap_or_default()
4066            .contains("Before creating, check list/show"));
4067    }
4068
4069    #[test]
4070    fn mana_template_outputs_task_template() {
4071        let output = mana_template_output(TemplateKind::Task, Some(GuideTopic::Verify));
4072
4073        assert!(!output.is_error);
4074        assert_eq!(output.details["action"], json!("template"));
4075        assert_eq!(output.details["kind"], json!("task"));
4076        assert_eq!(output.details["template"]["fail_first"], json!(true));
4077        assert!(output.details["template"]["verify"].is_string());
4078    }
4079
4080    #[test]
4081    fn mana_guide_and_template_validate_topic_and_kind() {
4082        assert!(parse_guide_topic(&json!({ "topic": "orchestrate" })).is_ok());
4083        assert!(parse_guide_topic(&json!({ "topic": "bad" }))
4084            .unwrap_err()
4085            .to_string()
4086            .contains("Invalid mana guide topic"));
4087        assert!(parse_template_kind(&json!({ "kind": "fact" })).is_ok());
4088        assert!(parse_template_kind(&json!({ "kind": "job" }))
4089            .unwrap_err()
4090            .to_string()
4091            .contains("Invalid mana template kind"));
4092    }
4093
4094    #[test]
4095    fn mana_schema_uses_canonical_fields_only() {
4096        let schema = ManaTool::default().parameters();
4097        let properties = schema["properties"].as_object().unwrap();
4098
4099        assert!(properties.contains_key("action"));
4100        assert!(properties.contains_key("id"));
4101        assert!(properties.contains_key("run_id"));
4102        assert!(properties.contains_key("title"));
4103        assert!(properties.contains_key("description"));
4104        assert!(properties.contains_key("acceptance"));
4105        assert!(properties.contains_key("verify"));
4106        assert!(properties.contains_key("notes"));
4107        assert!(properties.contains_key("decisions"));
4108        assert!(properties.contains_key("paths"));
4109        assert!(properties.contains_key("deps"));
4110        assert!(properties.contains_key("labels"));
4111        assert!(properties.contains_key("targets"));
4112        assert!(properties.contains_key("scope"));
4113        assert!(properties.contains_key("path"));
4114        assert!(properties.contains_key("parent_reason"));
4115        let actions = properties["action"]["enum"].as_array().unwrap();
4116        assert!(actions.iter().any(|value| value == "reparent"));
4117
4118        assert!(!properties.contains_key("mana_scope"));
4119        assert!(!properties.contains_key("mana_dir"));
4120        assert!(!properties.contains_key("paths_csv"));
4121        assert!(!properties.contains_key("fact_title"));
4122        assert!(!properties.contains_key("add_label"));
4123        assert!(!properties.contains_key("remove_label"));
4124
4125        let kind_enum = properties["kind"]["enum"].as_array().unwrap();
4126        assert!(kind_enum.iter().any(|value| value == "task"));
4127        assert!(!kind_enum.iter().any(|value| value == "job"));
4128    }
4129
4130    #[test]
4131    fn mana_validation_teaches_required_fields() {
4132        let output = validate_mana_action("notes_append", &json!({ "id": "304" }))
4133            .expect("notes_append without notes should fail validation");
4134
4135        assert!(output.is_error);
4136        assert_eq!(output.details["action"], json!("notes_append"));
4137        assert_eq!(output.details["missing"], json!(["notes"]));
4138        assert_eq!(output.details["canonical_fields"], json!(["id", "notes"]));
4139        assert!(output
4140            .text_content()
4141            .unwrap_or_default()
4142            .contains("requires id and notes"));
4143    }
4144
4145    #[test]
4146    fn mana_validation_rejects_path_when_paths_is_intended() {
4147        let output = validate_mana_action(
4148            "create",
4149            &json!({ "title": "Doc task", "path": "src/lib.rs" }),
4150        )
4151        .expect("create with path attachment should teach paths");
4152
4153        assert!(output.is_error);
4154        assert_eq!(output.details["invalid"], json!(["path"]));
4155        assert!(output
4156            .text_content()
4157            .unwrap_or_default()
4158            .contains("Use path for project/.mana location"));
4159    }
4160
4161    #[test]
4162    fn mana_validation_allows_valid_create_and_decision_add() {
4163        assert!(validate_mana_action("create", &json!({ "title": "Build thing" })).is_none());
4164        assert!(validate_mana_action(
4165            "decision_add",
4166            &json!({ "id": "304", "decisions": ["Use canonical names"] }),
4167        )
4168        .is_none());
4169    }
4170
4171    #[test]
4172    fn mana_validation_requires_parent_for_reparent() {
4173        let output = validate_mana_action("reparent", &json!({ "id": "313.2" }))
4174            .expect("reparent without parent should fail validation");
4175
4176        assert!(output.is_error);
4177        assert_eq!(output.details["missing"], json!(["parent"]));
4178        assert_eq!(
4179            output.details["canonical_fields"],
4180            json!(["id", "parent", "reason"])
4181        );
4182    }
4183
4184    #[tokio::test]
4185    async fn reparent_moves_child_between_parents() {
4186        let dir = tempfile::tempdir().unwrap();
4187        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4188        let tool = ManaTool::default();
4189
4190        let old_parent = tool
4191            .execute(
4192                "old_parent",
4193                json!({ "action": "create", "title": "Old Parent" }),
4194                ctx.clone(),
4195            )
4196            .await
4197            .unwrap();
4198        let old_parent_id = old_parent.details["unit"]["id"]
4199            .as_str()
4200            .unwrap()
4201            .to_string();
4202        let new_parent = tool
4203            .execute(
4204                "new_parent",
4205                json!({ "action": "create", "title": "New Parent" }),
4206                ctx.clone(),
4207            )
4208            .await
4209            .unwrap();
4210        let new_parent_id = new_parent.details["unit"]["id"]
4211            .as_str()
4212            .unwrap()
4213            .to_string();
4214        let child = tool
4215            .execute(
4216                "child",
4217                json!({ "action": "create", "title": "Child", "parent": old_parent_id, "parent_reason": "Initial grouping" }),
4218                ctx.clone(),
4219            )
4220            .await
4221            .unwrap();
4222        let child_id = child.details["unit"]["id"].as_str().unwrap().to_string();
4223
4224        let moved = tool
4225            .execute(
4226                "move_child",
4227                json!({
4228                    "action": "reparent",
4229                    "id": child_id,
4230                    "parent": new_parent_id,
4231                    "reason": "Belongs under the new reliability epic"
4232                }),
4233                ctx.clone(),
4234            )
4235            .await
4236            .unwrap();
4237
4238        assert!(!moved.is_error);
4239        assert_eq!(moved.details["action"], json!("reparent"));
4240        assert_eq!(moved.details["old_parent"], json!(old_parent_id));
4241        assert_eq!(moved.details["new_parent"], json!(new_parent_id));
4242        assert!(moved
4243            .text_content()
4244            .unwrap_or_default()
4245            .contains("reparented"));
4246    }
4247
4248    #[tokio::test]
4249    async fn update_supports_acceptance_labels_and_decisions() {
4250        let dir = tempfile::tempdir().unwrap();
4251        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4252        let tool = ManaTool::default();
4253        let _created = tool
4254            .execute(
4255                "call_create_update_target",
4256                json!({ "action": "create", "title": "Update target", "verify": "test -n ok" }),
4257                ctx,
4258            )
4259            .await
4260            .unwrap();
4261
4262        let dir2 = tempfile::tempdir().unwrap();
4263        let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
4264        std::fs::write(
4265            dir2.path().join(".mana").join("1-test-unit.md"),
4266            "---\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",
4267        ).unwrap();
4268        let result = tool
4269            .execute(
4270                "call_update_rich",
4271                json!({
4272                    "action": "update",
4273                    "id": "1",
4274                    "acceptance": "must pass auth flow",
4275                    "labels": ["backend"],
4276                    "decisions": ["Choose retry limit"],
4277                    "resolve_decisions": []
4278                }),
4279                ctx2,
4280            )
4281            .await
4282            .unwrap();
4283        let unit = &result.details["unit"];
4284        assert_eq!(unit["acceptance"], "must pass auth flow");
4285        assert_eq!(
4286            unit["labels"].as_array().cloned().unwrap_or_default(),
4287            vec![json!("backend")]
4288        );
4289        assert_eq!(unit["decisions"][0], "Choose retry limit");
4290    }
4291
4292    #[tokio::test]
4293    async fn create_respects_verify_lint_by_default() {
4294        let dir = tempfile::tempdir().unwrap();
4295        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4296        let tool = ManaTool::default();
4297        let result = tool
4298            .execute(
4299                "call_create_lint",
4300                json!({ "action": "create", "title": "Weak verify", "verify": "echo done" }),
4301                ctx,
4302            )
4303            .await
4304            .unwrap();
4305        assert!(result.is_error, "weak verify should be rejected by default");
4306        let text = result.text_content().unwrap_or("");
4307        assert!(text.contains("Verify command has lint errors") || text.contains("verify"));
4308    }
4309
4310    #[tokio::test]
4311    async fn native_verify_reopen_and_fact_actions_work() {
4312        let dir = tempfile::tempdir().unwrap();
4313        let mana_dir = dir.path().join(".mana");
4314        std::fs::create_dir_all(&mana_dir).unwrap();
4315        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4316        std::fs::write(
4317            mana_dir.join("1-test-unit.md"),
4318            "---\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",
4319        ).unwrap();
4320        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4321        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4322        let ctx = ToolContext {
4323            cwd: dir.path().to_path_buf(),
4324            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4325            update_tx: tx,
4326            command_tx: cmd_tx,
4327            ui: Arc::new(NullInterface),
4328            file_cache: Arc::new(FileCache::new()),
4329            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4330            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4331            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4332            lua_tool_loader: None,
4333            mode: crate::config::AgentMode::Full,
4334            read_max_lines: 500,
4335            turn_mana_review: Arc::new(std::sync::Mutex::new(
4336                crate::mana_review::TurnManaReviewAccumulator::default(),
4337            )),
4338            config: Arc::new(crate::config::Config::default()),
4339            run_policy: Default::default(),
4340            supporting_provenance: Vec::new(),
4341        };
4342        let tool = ManaTool::default();
4343        let reopened = tool
4344            .execute("call_reopen", json!({ "action": "reopen", "id": "1" }), ctx)
4345            .await
4346            .unwrap();
4347        assert_eq!(reopened.details["unit"]["status"], "open");
4348
4349        let dir2 = tempfile::tempdir().unwrap();
4350        let mana_dir2 = dir2.path().join(".mana");
4351        std::fs::create_dir_all(&mana_dir2).unwrap();
4352        std::fs::write(mana_dir2.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4353        std::fs::write(
4354            mana_dir2.join("1-test-unit.md"),
4355            "---\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",
4356        ).unwrap();
4357        let (tx2, _rx2) = mpsc::channel::<ToolUpdate>(1);
4358        let (cmd_tx2, _cmd_rx2) = mpsc::channel(16);
4359        let ctx2 = ToolContext {
4360            cwd: dir2.path().to_path_buf(),
4361            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4362            update_tx: tx2,
4363            command_tx: cmd_tx2,
4364            ui: Arc::new(NullInterface),
4365            file_cache: Arc::new(FileCache::new()),
4366            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4367            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4368            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4369            lua_tool_loader: None,
4370            mode: crate::config::AgentMode::Full,
4371            read_max_lines: 500,
4372            turn_mana_review: Arc::new(std::sync::Mutex::new(
4373                crate::mana_review::TurnManaReviewAccumulator::default(),
4374            )),
4375            config: Arc::new(crate::config::Config::default()),
4376            run_policy: Default::default(),
4377            supporting_provenance: Vec::new(),
4378        };
4379        let verify = tool
4380            .execute(
4381                "call_verify",
4382                json!({ "action": "verify", "id": "1" }),
4383                ctx2,
4384            )
4385            .await
4386            .unwrap();
4387        assert_eq!(verify.details["passed"], true);
4388
4389        let dir3 = tempfile::tempdir().unwrap();
4390        let mana_dir3 = dir3.path().join(".mana");
4391        std::fs::create_dir_all(&mana_dir3).unwrap();
4392        std::fs::write(mana_dir3.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
4393        let (tx3, _rx3) = mpsc::channel::<ToolUpdate>(1);
4394        let (cmd_tx3, _cmd_rx3) = mpsc::channel(16);
4395        let ctx3 = ToolContext {
4396            cwd: dir3.path().to_path_buf(),
4397            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4398            update_tx: tx3,
4399            command_tx: cmd_tx3,
4400            ui: Arc::new(NullInterface),
4401            file_cache: Arc::new(FileCache::new()),
4402            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4403            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4404            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4405            lua_tool_loader: None,
4406            mode: crate::config::AgentMode::Full,
4407            read_max_lines: 500,
4408            turn_mana_review: Arc::new(std::sync::Mutex::new(
4409                crate::mana_review::TurnManaReviewAccumulator::default(),
4410            )),
4411            config: Arc::new(crate::config::Config::default()),
4412            run_policy: Default::default(),
4413            supporting_provenance: Vec::new(),
4414        };
4415        let fact = tool.execute("call_fact", json!({ "action": "fact_create", "title": "Auth fact", "verify": "test -d .mana", "description": "fact body", "ttl_days": 7 }), ctx3).await.unwrap();
4416        assert_eq!(fact.details["unit"]["unit_type"], "fact");
4417    }
4418
4419    #[tokio::test]
4420    async fn notes_append_is_safe_partial_update() {
4421        let dir = tempfile::tempdir().unwrap();
4422        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4423        let tool = ManaTool::default();
4424        let result = tool
4425            .execute(
4426                "call_notes_append",
4427                json!({
4428                    "action": "notes_append",
4429                    "id": "1",
4430                    "notes": "diagnosis from turn 2"
4431                }),
4432                ctx,
4433            )
4434            .await
4435            .unwrap();
4436        let unit = &result.details["unit"];
4437        assert_eq!(unit["title"], "Test unit");
4438        assert!(unit["notes"]
4439            .as_str()
4440            .unwrap_or("")
4441            .contains("diagnosis from turn 2"));
4442    }
4443
4444    #[tokio::test]
4445    async fn decision_add_and_resolve_work() {
4446        let dir = tempfile::tempdir().unwrap();
4447        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4448        let tool = ManaTool::default();
4449        let added = tool
4450            .execute(
4451                "call_decision_add",
4452                json!({
4453                    "action": "decision_add",
4454                    "id": "1",
4455                    "description": "Choose retry limit"
4456                }),
4457                ctx,
4458            )
4459            .await
4460            .unwrap();
4461        assert_eq!(added.details["unit"]["decisions"][0], "Choose retry limit");
4462
4463        let dir2 = tempfile::tempdir().unwrap();
4464        let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
4465        std::fs::write(
4466            dir2.path().join(".mana").join("1-test-unit.md"),
4467            "---\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",
4468        ).unwrap();
4469        let resolved = tool
4470            .execute(
4471                "call_decision_resolve",
4472                json!({
4473                    "action": "decision_resolve",
4474                    "id": "1",
4475                    "resolve_decisions": ["Choose retry limit"]
4476                }),
4477                ctx2,
4478            )
4479            .await
4480            .unwrap();
4481        let decisions = resolved.details["unit"]["decisions"]
4482            .as_array()
4483            .cloned()
4484            .unwrap_or_default();
4485        assert!(decisions.is_empty());
4486    }
4487
4488    #[tokio::test]
4489    async fn show_returns_archived_unit_when_active_missing() {
4490        let dir = tempfile::tempdir().unwrap();
4491        let mana_dir = dir.path().join(".mana");
4492        std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
4493        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4494        std::fs::write(
4495            mana_dir.join("archive/2026/04/1-archived-unit.md"),
4496            "---\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",
4497        )
4498        .unwrap();
4499        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4500        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4501        let ctx = ToolContext {
4502            cwd: dir.path().to_path_buf(),
4503            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4504            update_tx: tx,
4505            command_tx: cmd_tx,
4506            ui: Arc::new(NullInterface),
4507            file_cache: Arc::new(FileCache::new()),
4508            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4509            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4510            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4511            lua_tool_loader: None,
4512            mode: crate::config::AgentMode::Full,
4513            read_max_lines: 500,
4514            turn_mana_review: Arc::new(std::sync::Mutex::new(
4515                crate::mana_review::TurnManaReviewAccumulator::default(),
4516            )),
4517            config: Arc::new(crate::config::Config::default()),
4518            run_policy: Default::default(),
4519            supporting_provenance: Vec::new(),
4520        };
4521        let tool = ManaTool::default();
4522        let result = tool
4523            .execute(
4524                "call_show_archived",
4525                json!({ "action": "show", "id": "1" }),
4526                ctx,
4527            )
4528            .await
4529            .unwrap();
4530
4531        assert!(!result.is_error);
4532        assert_eq!(result.details["title"], "Archived unit");
4533        assert_eq!(result.details["is_archived"], true);
4534    }
4535
4536    #[tokio::test]
4537    async fn tree_reports_archived_root_as_active_only_limitation() {
4538        let dir = tempfile::tempdir().unwrap();
4539        let mana_dir = dir.path().join(".mana");
4540        std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
4541        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4542        std::fs::write(
4543            mana_dir.join("archive/2026/04/1-archived-unit.md"),
4544            "---\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",
4545        )
4546        .unwrap();
4547        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4548        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4549        let ctx = ToolContext {
4550            cwd: dir.path().to_path_buf(),
4551            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4552            update_tx: tx,
4553            command_tx: cmd_tx,
4554            ui: Arc::new(NullInterface),
4555            file_cache: Arc::new(FileCache::new()),
4556            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4557            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4558            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4559            lua_tool_loader: None,
4560            mode: crate::config::AgentMode::Full,
4561            read_max_lines: 500,
4562            turn_mana_review: Arc::new(std::sync::Mutex::new(
4563                crate::mana_review::TurnManaReviewAccumulator::default(),
4564            )),
4565            config: Arc::new(crate::config::Config::default()),
4566            run_policy: Default::default(),
4567            supporting_provenance: Vec::new(),
4568        };
4569        let tool = ManaTool::default();
4570        let result = tool
4571            .execute(
4572                "call_tree_archived",
4573                json!({ "action": "tree", "id": "1" }),
4574                ctx,
4575            )
4576            .await
4577            .unwrap();
4578
4579        assert!(result.is_error);
4580        let text = result.text_content().unwrap_or("");
4581        assert!(text.contains("Archived unit 1 can be shown but not rendered in tree view"));
4582    }
4583
4584    #[tokio::test]
4585    async fn root_scope_targets_outermost_mana() {
4586        let tower = tempfile::tempdir().unwrap();
4587        let root_mana = tower.path().join(".mana");
4588        std::fs::create_dir_all(&root_mana).unwrap();
4589        std::fs::write(root_mana.join("config.yaml"), "project: root\nnext_id: 2\n").unwrap();
4590        std::fs::write(
4591            root_mana.join("1-root-unit.md"),
4592            "---\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",
4593        ).unwrap();
4594        let project = tower.path().join("imp");
4595        let project_mana = project.join(".mana");
4596        std::fs::create_dir_all(&project_mana).unwrap();
4597        std::fs::write(
4598            project_mana.join("config.yaml"),
4599            "project: nested\nnext_id: 2\n",
4600        )
4601        .unwrap();
4602        std::fs::write(
4603            project_mana.join("1-project-unit.md"),
4604            "---\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",
4605        ).unwrap();
4606        let workdir = project.join("src");
4607        std::fs::create_dir_all(&workdir).unwrap();
4608        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4609        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4610        let ctx = ToolContext {
4611            cwd: workdir,
4612            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4613            update_tx: tx,
4614            command_tx: cmd_tx,
4615            ui: Arc::new(NullInterface),
4616            file_cache: Arc::new(FileCache::new()),
4617            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4618            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4619            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4620            lua_tool_loader: None,
4621            mode: crate::config::AgentMode::Full,
4622            read_max_lines: 500,
4623            turn_mana_review: Arc::new(std::sync::Mutex::new(
4624                crate::mana_review::TurnManaReviewAccumulator::default(),
4625            )),
4626            config: Arc::new(crate::config::Config::default()),
4627            run_policy: Default::default(),
4628            supporting_provenance: Vec::new(),
4629        };
4630        let tool = ManaTool::default();
4631        let result = tool
4632            .execute(
4633                "call_root_scope",
4634                json!({ "action": "show", "id": "1", "scope": "root" }),
4635                ctx,
4636            )
4637            .await
4638            .unwrap();
4639        assert_eq!(result.details["title"], "Root unit");
4640    }
4641
4642    #[tokio::test]
4643    async fn explicit_path_targets_project_outside_cwd_ancestry() {
4644        let outside = tempfile::tempdir().unwrap();
4645        let target_project = outside.path().join("other-project");
4646        let target_mana = target_project.join(".mana");
4647        std::fs::create_dir_all(&target_mana).unwrap();
4648        std::fs::write(
4649            target_mana.join("config.yaml"),
4650            "project: other\nnext_id: 2\n",
4651        )
4652        .unwrap();
4653        std::fs::write(
4654            target_mana.join("1-other-unit.md"),
4655            "---\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",
4656        )
4657        .unwrap();
4658
4659        let unrelated = tempfile::tempdir().unwrap();
4660        let workdir = unrelated.path().join("scratch");
4661        std::fs::create_dir_all(&workdir).unwrap();
4662        let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4663        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4664        let ctx = ToolContext {
4665            cwd: workdir,
4666            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4667            update_tx: tx,
4668            command_tx: cmd_tx,
4669            ui: Arc::new(NullInterface),
4670            file_cache: Arc::new(FileCache::new()),
4671            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4672            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4673            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4674            lua_tool_loader: None,
4675            mode: crate::config::AgentMode::Full,
4676            read_max_lines: 500,
4677            turn_mana_review: Arc::new(std::sync::Mutex::new(
4678                crate::mana_review::TurnManaReviewAccumulator::default(),
4679            )),
4680            config: Arc::new(crate::config::Config::default()),
4681            run_policy: Default::default(),
4682            supporting_provenance: Vec::new(),
4683        };
4684        let tool = ManaTool::default();
4685        let result = tool
4686            .execute(
4687                "call_explicit_path",
4688                json!({ "action": "show", "id": "1", "path": target_project }),
4689                ctx,
4690            )
4691            .await
4692            .unwrap();
4693        assert_eq!(result.details["title"], "Other unit");
4694    }
4695
4696    #[tokio::test]
4697    async fn worker_blocks_fact_create() {
4698        match run_with_mode("worker", "fact_create").await {
4699            ManaResult::ModeBlocked(_) => {}
4700            ManaResult::Attempted(out) => {
4701                panic!(
4702                    "worker should block 'fact_create', got: {:?}",
4703                    out.text_content()
4704                )
4705            }
4706        }
4707    }
4708
4709    #[tokio::test]
4710    async fn worker_allows_verify() {
4711        match run_with_mode("worker", "verify").await {
4712            ManaResult::Attempted(_) => {}
4713            ManaResult::ModeBlocked(msg) => {
4714                panic!("worker should allow 'verify' but was blocked: {msg}")
4715            }
4716        }
4717    }
4718
4719    #[tokio::test]
4720    async fn auditor_allows_show() {
4721        match run_with_mode("auditor", "show").await {
4722            ManaResult::Attempted(_) => {}
4723            ManaResult::ModeBlocked(msg) => {
4724                panic!("auditor should allow 'show' but was blocked: {msg}")
4725            }
4726        }
4727    }
4728
4729    #[tokio::test]
4730    async fn auditor_blocks_update() {
4731        match run_with_mode("auditor", "update").await {
4732            ManaResult::ModeBlocked(_) => {}
4733            ManaResult::Attempted(out) => {
4734                panic!(
4735                    "auditor should block 'update', got: {:?}",
4736                    out.text_content()
4737                )
4738            }
4739        }
4740    }
4741
4742    #[tokio::test]
4743    async fn worker_allows_logs() {
4744        match run_with_mode("worker", "logs").await {
4745            ManaResult::Attempted(_) => {}
4746            ManaResult::ModeBlocked(msg) => {
4747                panic!("worker should allow 'logs' but was blocked: {msg}")
4748            }
4749        }
4750    }
4751
4752    #[tokio::test]
4753    async fn orchestrator_allows_extended_actions() {
4754        for action in &[
4755            "status",
4756            "list",
4757            "show",
4758            "create",
4759            "close",
4760            "update",
4761            "run",
4762            "run_state",
4763            "evaluate",
4764            "claim",
4765            "release",
4766            "logs",
4767            "agents",
4768            "next",
4769        ] {
4770            match run_with_mode("orchestrator", action).await {
4771                ManaResult::Attempted(_) => {}
4772                ManaResult::ModeBlocked(msg) => {
4773                    panic!("orchestrator should allow '{action}' but was blocked: {msg}")
4774                }
4775            }
4776        }
4777    }
4778
4779    #[tokio::test]
4780    async fn ctx_mode_wins_over_env() {
4781        let prev = {
4782            let _guard = ENV_LOCK.lock().unwrap();
4783            let prev = std::env::var("IMP_MODE").ok();
4784            std::env::set_var("IMP_MODE", "full");
4785            prev
4786        };
4787
4788        let result = run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await;
4789
4790        match prev {
4791            Some(v) => {
4792                let _guard = ENV_LOCK.lock().unwrap();
4793                std::env::set_var("IMP_MODE", v)
4794            }
4795            None => {
4796                let _guard = ENV_LOCK.lock().unwrap();
4797                std::env::remove_var("IMP_MODE")
4798            }
4799        }
4800
4801        match result {
4802            ManaResult::ModeBlocked(_) => {}
4803            ManaResult::Attempted(out) => {
4804                panic!(
4805                    "ctx.mode=Worker should block 'create' even when IMP_MODE=full, got: {:?}",
4806                    out.text_content()
4807                )
4808            }
4809        }
4810    }
4811
4812    #[tokio::test]
4813    async fn ctx_worker_blocks_create() {
4814        match run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await {
4815            ManaResult::ModeBlocked(_) => {}
4816            ManaResult::Attempted(out) => {
4817                panic!(
4818                    "ctx Worker mode should block 'create', got: {:?}",
4819                    out.text_content()
4820                )
4821            }
4822        }
4823    }
4824
4825    #[tokio::test]
4826    async fn ctx_full_allows_extended_actions() {
4827        for action in &[
4828            "status",
4829            "list",
4830            "show",
4831            "create",
4832            "close",
4833            "update",
4834            "run",
4835            "run_state",
4836            "evaluate",
4837            "claim",
4838            "release",
4839            "logs",
4840            "agents",
4841            "next",
4842            "tree",
4843        ] {
4844            match run_with_ctx_mode(crate::config::AgentMode::Full, action).await {
4845                ManaResult::Attempted(_) => {}
4846                ManaResult::ModeBlocked(msg) => {
4847                    panic!("ctx Full mode should allow '{action}' but was blocked: {msg}")
4848                }
4849            }
4850        }
4851    }
4852
4853    #[tokio::test]
4854    async fn next_returns_ranked_text() {
4855        let dir = tempfile::tempdir().unwrap();
4856        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4857        let tool = ManaTool::default();
4858        let result = tool
4859            .execute("call_next", json!({ "action": "next", "count": 1 }), ctx)
4860            .await
4861            .unwrap();
4862        let text = result.text_content().unwrap_or("");
4863        assert!(text.contains("Test unit") || text.contains("No ready units"));
4864    }
4865
4866    #[tokio::test]
4867    async fn run_returns_promptly() {
4868        let dir = tempfile::tempdir().unwrap();
4869        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4870        let tool = ManaTool::default();
4871        let result = tool
4872            .execute("call_run", json!({ "action": "run", "dry_run": true }), ctx)
4873            .await
4874            .unwrap();
4875        let text = result.text_content().unwrap_or("");
4876        assert!(text.contains("Started native mana orchestration"));
4877        assert!(result.details["run_id"].as_str().is_some());
4878    }
4879
4880    #[tokio::test]
4881    async fn run_enqueues_follow_up_on_completion_without_ui() {
4882        let dir = tempfile::tempdir().unwrap();
4883        let mana_dir = dir.path().join(".mana");
4884        std::fs::create_dir_all(&mana_dir).unwrap();
4885        std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4886        std::fs::write(
4887            mana_dir.join("1-test-unit.md"),
4888            "---\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",
4889        )
4890        .unwrap();
4891
4892        let (tx, _rx) = mpsc::channel::<ToolUpdate>(8);
4893        let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
4894        let ctx = ToolContext {
4895            cwd: dir.path().to_path_buf(),
4896            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4897            update_tx: tx,
4898            command_tx: cmd_tx,
4899            ui: Arc::new(NullInterface),
4900            file_cache: Arc::new(FileCache::new()),
4901            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4902            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4903            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4904            lua_tool_loader: None,
4905            mode: crate::config::AgentMode::Full,
4906            read_max_lines: 500,
4907            turn_mana_review: Arc::new(std::sync::Mutex::new(
4908                crate::mana_review::TurnManaReviewAccumulator::default(),
4909            )),
4910            config: Arc::new(crate::config::Config::default()),
4911            run_policy: Default::default(),
4912            supporting_provenance: Vec::new(),
4913        };
4914
4915        let tool = ManaTool::default();
4916        let _ = tool
4917            .execute(
4918                "call_bg_follow_up",
4919                json!({ "action": "run", "dry_run": true }),
4920                ctx,
4921            )
4922            .await
4923            .unwrap();
4924
4925        let follow_up = tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv())
4926            .await
4927            .expect("follow-up timeout")
4928            .expect("follow-up message");
4929
4930        match follow_up {
4931            crate::agent::AgentCommand::FollowUp(text) => {
4932                assert!(
4933                    text.contains("Native mana orchestration finished"),
4934                    "text was: {text}"
4935                );
4936                assert!(
4937                    text.contains("Inspect with mana(action=\"run_state\")"),
4938                    "text was: {text}"
4939                );
4940            }
4941            other => panic!("expected follow-up, got {other:?}"),
4942        }
4943    }
4944
4945    #[tokio::test]
4946    async fn run_with_ui_does_not_enqueue_follow_up_on_completion() {
4947        let dir = tempfile::tempdir().unwrap();
4948        let (ctx, _keep, _widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
4949        let tool = ManaTool::default();
4950        let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
4951        let ctx = ToolContext {
4952            command_tx: cmd_tx,
4953            ..ctx
4954        };
4955
4956        let _ = tool
4957            .execute(
4958                "call_bg_follow_up_ui",
4959                json!({ "action": "run", "dry_run": true }),
4960                ctx,
4961            )
4962            .await
4963            .unwrap();
4964
4965        let follow_up =
4966            tokio::time::timeout(std::time::Duration::from_millis(700), cmd_rx.recv()).await;
4967        match follow_up {
4968            Err(_) | Ok(None) => {}
4969            Ok(Some(msg)) => panic!(
4970                "UI mode should rely on widget/status instead of queueing duplicate follow-up chat text, got: {msg:?}"
4971            ),
4972        }
4973    }
4974
4975    #[tokio::test]
4976    async fn run_supports_explicit_targets() {
4977        let dir = tempfile::tempdir().unwrap();
4978        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4979        let tool = ManaTool::default();
4980        let result = tool
4981            .execute(
4982                "call_bg_targets",
4983                json!({ "action": "run", "dry_run": true, "targets": ["1", "2"] }),
4984                ctx,
4985            )
4986            .await
4987            .unwrap();
4988        assert_eq!(result.details["target"]["kind"], "explicit");
4989        assert_eq!(result.details["target"]["ids"][0], "1");
4990        assert_eq!(result.details["target"]["ids"][1], "2");
4991    }
4992
4993    #[tokio::test]
4994    async fn run_state_and_evaluate_report_native_run() {
4995        let dir = tempfile::tempdir().unwrap();
4996        let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4997        let tool = ManaTool::default();
4998
4999        let run_result = tool
5000            .execute("call_run", json!({ "action": "run", "dry_run": true }), ctx)
5001            .await
5002            .unwrap();
5003        let run_id = run_result.details["run_id"]
5004            .as_str()
5005            .expect("run_id")
5006            .to_string();
5007
5008        let dir2 = tempfile::tempdir().unwrap();
5009        let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
5010        let state = tool
5011            .execute(
5012                "call_state",
5013                json!({ "action": "run_state", "run_id": run_id.as_str() }),
5014                ctx2,
5015            )
5016            .await
5017            .unwrap();
5018        let state_text = state.text_content().unwrap_or("");
5019        assert!(
5020            state_text.contains("Native mana orchestration "),
5021            "state_text was: {state_text}"
5022        );
5023        assert!(
5024            state_text.contains("Worker runtime:")
5025                || state_text.contains("Next: Inspect logs/agents"),
5026            "state_text was: {state_text}"
5027        );
5028        assert!(
5029            state_text.contains("Units:")
5030                || state_text.contains("Latest: Dry run:")
5031                || state_text.contains("0 total"),
5032            "state_text was: {state_text}"
5033        );
5034        assert!(
5035            state_text.contains("all ready units") || state_text.contains("unit"),
5036            "state_text was: {state_text}"
5037        );
5038
5039        let dir3 = tempfile::tempdir().unwrap();
5040        let (ctx3, _keep3) = ctx_with_mode(dir3.path(), crate::config::AgentMode::Full);
5041        let evaluation = tool
5042            .execute(
5043                "call_eval",
5044                json!({ "action": "evaluate", "run_id": run_result.details["run_id"] }),
5045                ctx3,
5046            )
5047            .await
5048            .unwrap();
5049        let eval_text = evaluation.text_content().unwrap_or("");
5050        assert!(
5051            eval_text.contains("Native mana orchestration run ")
5052                && (eval_text.contains("finished") || eval_text.contains("still running")),
5053            "eval_text was: {eval_text}"
5054        );
5055        assert!(
5056            eval_text.contains("Worker runtime:") || eval_text.contains("Runtime:"),
5057            "eval_text was: {eval_text}"
5058        );
5059    }
5060
5061    #[test]
5062    fn run_store_prefers_active_run_snapshot() {
5063        let mut store = ManaRunStore::default();
5064        let active_id = store.start_run(
5065            "all ready units".to_string(),
5066            &mana::commands::run::NativeRunParams {
5067                target: mana::commands::run::RunTarget::AllReady,
5068                jobs: 2,
5069                dry_run: false,
5070                loop_mode: false,
5071                keep_going: false,
5072                timeout: 30,
5073                idle_timeout: 5,
5074                json_stream: true,
5075                review: false,
5076                run_model: None,
5077                run_thinking: None,
5078            },
5079        );
5080        let finished_id = store.start_run(
5081            "unit 1".to_string(),
5082            &mana::commands::run::NativeRunParams {
5083                target: mana::commands::run::RunTarget::Unit("1".to_string()),
5084                jobs: 1,
5085                dry_run: true,
5086                loop_mode: false,
5087                keep_going: false,
5088                timeout: 30,
5089                idle_timeout: 5,
5090                json_stream: true,
5091                review: false,
5092                run_model: None,
5093                run_thinking: None,
5094            },
5095        );
5096        store.fail_run(&finished_id, "done".to_string());
5097
5098        let latest = store.snapshot(None).expect("snapshot");
5099        assert_eq!(latest.run_id, active_id);
5100        assert_eq!(latest.status, "starting");
5101    }
5102
5103    #[test]
5104    fn stream_event_line_formats_tool_activity() {
5105        let line = stream_event_line(&mana::stream::StreamEvent::UnitTool {
5106            id: "1".to_string(),
5107            tool_name: "read".to_string(),
5108            tool_count: 3,
5109            file_path: Some("src/lib.rs".to_string()),
5110        })
5111        .expect("line");
5112        assert!(line.contains("#3 read"));
5113        assert!(line.contains("src/lib.rs"));
5114    }
5115
5116    #[test]
5117    fn evaluate_output_reports_failures() {
5118        let mut state = NativeRunState::new(
5119            "run-7".to_string(),
5120            "unit 7".to_string(),
5121            &mana::commands::run::NativeRunParams {
5122                target: mana::commands::run::RunTarget::Unit("7".to_string()),
5123                jobs: 1,
5124                dry_run: false,
5125                loop_mode: false,
5126                keep_going: false,
5127                timeout: 30,
5128                idle_timeout: 5,
5129                json_stream: true,
5130                review: false,
5131                run_model: None,
5132                run_thinking: None,
5133            },
5134        );
5135        state.status = "finished".to_string();
5136        state.summary.total_failed = 2;
5137        state.log_lines.push("✗ 7 failed verify".to_string());
5138
5139        let output = evaluate_run_output(&state);
5140        let text = output.text_content().unwrap_or("");
5141        assert!(text.contains("2 failed unit"));
5142        assert!(text.contains("Latest: ✗ 7 failed verify"));
5143        assert!(text.contains("Next: Inspect failed units"));
5144        assert_eq!(
5145            output.details["recovery"]["retry_requires_unit_update"],
5146            json!(true)
5147        );
5148        assert!(output.details["recovery"]["next_actions"]
5149            .as_array()
5150            .unwrap()
5151            .iter()
5152            .any(|action| action
5153                .as_str()
5154                .unwrap_or_default()
5155                .contains("do not rerun unchanged")));
5156    }
5157
5158    #[test]
5159    fn run_state_output_includes_recovery_details() {
5160        let mut state = NativeRunState::new(
5161            "run-8".to_string(),
5162            "unit 8".to_string(),
5163            &mana::commands::run::NativeRunParams {
5164                target: mana::commands::run::RunTarget::Unit("8".to_string()),
5165                jobs: 1,
5166                dry_run: false,
5167                loop_mode: false,
5168                keep_going: false,
5169                timeout: 30,
5170                idle_timeout: 5,
5171                json_stream: true,
5172                review: false,
5173                run_model: None,
5174                run_thinking: None,
5175            },
5176        );
5177        state.status = "failed".to_string();
5178        state.summary.total_failed = 1;
5179        state.units.push(RunUnitStatus {
5180            id: "8".to_string(),
5181            title: "Failed unit".to_string(),
5182            status: "failed".to_string(),
5183            round: Some(1),
5184            agent: Some("imp-worker".to_string()),
5185            model: None,
5186            duration_secs: Some(3),
5187            tool_count: Some(2),
5188            turns: Some(1),
5189            failure_summary: Some("verify failed".to_string()),
5190            error: Some("exit 1".to_string()),
5191        });
5192
5193        let output = run_state_output(&state);
5194        let text = output.text_content().unwrap_or_default();
5195
5196        assert!(text.contains("Next: Inspect failed units"));
5197        assert_eq!(
5198            output.details["recovery"]["failed_units"][0]["id"],
5199            json!("8")
5200        );
5201        assert_eq!(
5202            output.details["recovery"]["retry_requires_unit_update"],
5203            json!(true)
5204        );
5205    }
5206    #[test]
5207    fn validate_native_run_target_rejects_multi_explicit_targets() {
5208        let target = RunTarget::Explicit(vec!["1".to_string(), "2".to_string()]);
5209
5210        let error = validate_native_run_target(&target).expect_err("must not broaden scope");
5211
5212        assert!(error.contains("multiple explicit targets"));
5213        assert!(error.contains("one target at a time"));
5214    }
5215
5216    #[test]
5217    fn target_ids_from_run_target_extracts_explicit_units() {
5218        assert_eq!(
5219            target_ids_from_run_target(&RunTarget::Unit("273.3".to_string())),
5220            vec!["273.3".to_string()]
5221        );
5222        assert_eq!(
5223            target_ids_from_run_target(&RunTarget::Explicit(vec![
5224                "1".to_string(),
5225                "2".to_string()
5226            ])),
5227            vec!["1".to_string(), "2".to_string()]
5228        );
5229        assert!(target_ids_from_run_target(&RunTarget::AllReady).is_empty());
5230    }
5231
5232    #[test]
5233    fn retry_guardrail_blocks_failed_unit_without_new_update() {
5234        let dir = tempfile::tempdir().unwrap();
5235        let mana_dir = dir.path().join(".mana");
5236        std::fs::create_dir(&mana_dir).unwrap();
5237        mana_core::config::Config {
5238            project: "retry-test".to_string(),
5239            next_id: 1,
5240            auto_close_parent: true,
5241            run: None,
5242            plan: None,
5243            max_loops: 10,
5244            max_concurrent: 4,
5245            poll_interval: 30,
5246            extends: vec![],
5247            rules_file: None,
5248            file_locking: false,
5249            worktree: false,
5250            on_close: None,
5251            on_fail: None,
5252            verify_timeout: None,
5253            review: None,
5254            user: None,
5255            user_email: None,
5256            auto_commit: false,
5257            commit_template: None,
5258            research: None,
5259            run_model: None,
5260            plan_model: None,
5261            review_model: None,
5262            research_model: None,
5263            batch_verify: false,
5264            memory_reserve_mb: 0,
5265            notify: None,
5266        }
5267        .save(&mana_dir)
5268        .unwrap();
5269
5270        let created = mana_core::api::create_unit(
5271            &mana_dir,
5272            mana_core::ops::create::CreateParams {
5273                title: "Retry target".to_string(),
5274                verify: Some("false".to_string()),
5275                ..Default::default()
5276            },
5277        )
5278        .unwrap();
5279        let id = created.unit.id;
5280        let now = chrono::Utc::now();
5281        let mut failed_unit = mana_core::ops::show::get(&mana_dir, &id).unwrap().unit;
5282        failed_unit
5283            .attempt_log
5284            .push(mana_core::unit::AttemptRecord {
5285                num: 1,
5286                outcome: mana_core::unit::AttemptOutcome::Failed,
5287                notes: Some("verify failed".to_string()),
5288                agent: Some("imp-test".to_string()),
5289                started_at: Some(now),
5290                finished_at: Some(now),
5291                autonomy_observation: None,
5292            });
5293        failed_unit.updated_at = now - chrono::Duration::milliseconds(1);
5294        let unit_path = mana_core::discovery::find_unit_file(&mana_dir, &id).unwrap();
5295        failed_unit.to_file(&unit_path).unwrap();
5296
5297        let guardrail = retry_guardrail_for_targets(&mana_dir, std::slice::from_ref(&id))
5298            .unwrap()
5299            .expect("failed unchanged unit should require update");
5300
5301        assert_eq!(guardrail["retry_requires_unit_update"], json!(true));
5302        assert_eq!(guardrail["blocked_units"][0]["id"], json!(id));
5303        assert!(guardrail["next_actions"]
5304            .as_array()
5305            .unwrap()
5306            .iter()
5307            .any(|action| action.as_str().unwrap_or_default().contains("Append notes")));
5308    }
5309
5310    #[test]
5311    fn retry_guardrail_allows_failed_unit_after_update() {
5312        let dir = tempfile::tempdir().unwrap();
5313        let mana_dir = dir.path().join(".mana");
5314        std::fs::create_dir(&mana_dir).unwrap();
5315        mana_core::config::Config {
5316            project: "retry-test".to_string(),
5317            next_id: 1,
5318            auto_close_parent: true,
5319            run: None,
5320            plan: None,
5321            max_loops: 10,
5322            max_concurrent: 4,
5323            poll_interval: 30,
5324            extends: vec![],
5325            rules_file: None,
5326            file_locking: false,
5327            worktree: false,
5328            on_close: None,
5329            on_fail: None,
5330            verify_timeout: None,
5331            review: None,
5332            user: None,
5333            user_email: None,
5334            auto_commit: false,
5335            commit_template: None,
5336            research: None,
5337            run_model: None,
5338            plan_model: None,
5339            review_model: None,
5340            research_model: None,
5341            batch_verify: false,
5342            memory_reserve_mb: 0,
5343            notify: None,
5344        }
5345        .save(&mana_dir)
5346        .unwrap();
5347
5348        let created = mana_core::api::create_unit(
5349            &mana_dir,
5350            mana_core::ops::create::CreateParams {
5351                title: "Retry target".to_string(),
5352                verify: Some("false".to_string()),
5353                ..Default::default()
5354            },
5355        )
5356        .unwrap();
5357        let id = created.unit.id;
5358        let now = chrono::Utc::now();
5359        let mut failed_unit = mana_core::ops::show::get(&mana_dir, &id).unwrap().unit;
5360        failed_unit
5361            .attempt_log
5362            .push(mana_core::unit::AttemptRecord {
5363                num: 1,
5364                outcome: mana_core::unit::AttemptOutcome::Failed,
5365                notes: Some("verify failed".to_string()),
5366                agent: Some("imp-test".to_string()),
5367                started_at: Some(now),
5368                finished_at: Some(now),
5369                autonomy_observation: None,
5370            });
5371        failed_unit.updated_at = now;
5372        let unit_path = mana_core::discovery::find_unit_file(&mana_dir, &id).unwrap();
5373        failed_unit.to_file(&unit_path).unwrap();
5374        std::thread::sleep(std::time::Duration::from_millis(2));
5375        mana_core::api::update_unit(
5376            &mana_dir,
5377            &id,
5378            mana_core::ops::update::UpdateParams {
5379                notes: Some("Changed retry plan after failure".to_string()),
5380                ..Default::default()
5381            },
5382        )
5383        .unwrap();
5384
5385        let guardrail = retry_guardrail_for_targets(&mana_dir, &[id]).unwrap();
5386
5387        assert!(guardrail.is_none());
5388    }
5389    fn native_run_params_for_test() -> mana::commands::run::NativeRunParams {
5390        mana::commands::run::NativeRunParams {
5391            target: mana::commands::run::RunTarget::AllReady,
5392            jobs: 1,
5393            dry_run: false,
5394            loop_mode: false,
5395            keep_going: false,
5396            timeout: 30,
5397            idle_timeout: 5,
5398            json_stream: true,
5399            review: false,
5400            run_model: None,
5401            run_thinking: None,
5402        }
5403    }
5404
5405    fn ready_unit_for_model_test(model: Option<&str>) -> mana_run_core::ReadyUnit {
5406        mana_run_core::ReadyUnit {
5407            id: "1".to_string(),
5408            title: "Model propagation".to_string(),
5409            priority: 2,
5410            critical_path_weight: 0,
5411            paths: Vec::new(),
5412            produces: Vec::new(),
5413            requires: Vec::new(),
5414            dependencies: Vec::new(),
5415            parent: None,
5416            model: model.map(str::to_string),
5417            verify_command: None,
5418            verify_fast: None,
5419            retry: mana_core::ops::run::RunRetryContext {
5420                attempt_number: 0,
5421                previous_failure: None,
5422                previous_notes: Vec::new(),
5423            },
5424        }
5425    }
5426
5427    #[test]
5428    fn native_worker_options_prefer_unit_model_and_config_thinking() {
5429        let dir = tempfile::tempdir().unwrap();
5430        let (mut ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
5431        let config = crate::config::Config {
5432            model: Some("config-model".to_string()),
5433            thinking: Some(imp_llm::ThinkingLevel::High),
5434            ..Default::default()
5435        };
5436        ctx.config = Arc::new(config);
5437        let run_args = mana::commands::run::NativeRunParams {
5438            timeout: 42,
5439            ..native_run_params_for_test()
5440        };
5441
5442        let options = worker_options_for_native_unit(
5443            &ready_unit_for_model_test(Some("unit-model")),
5444            &run_args,
5445            dir.path().to_path_buf(),
5446            dir.path().join(".mana"),
5447            &ctx,
5448        );
5449
5450        assert_eq!(options.model.as_deref(), Some("unit-model"));
5451        assert_eq!(options.thinking, Some(imp_llm::ThinkingLevel::High));
5452        assert_eq!(options.max_turns, Some(42));
5453        assert_eq!(
5454            options.mana_dir_override.as_deref(),
5455            Some(dir.path().join(".mana").as_path())
5456        );
5457    }
5458
5459    #[test]
5460    fn native_worker_options_fall_back_to_config_model() {
5461        let dir = tempfile::tempdir().unwrap();
5462        let (mut ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
5463        let config = crate::config::Config {
5464            model: Some("config-model".to_string()),
5465            ..Default::default()
5466        };
5467        ctx.config = Arc::new(config);
5468
5469        let options = worker_options_for_native_unit(
5470            &ready_unit_for_model_test(None),
5471            &native_run_params_for_test(),
5472            dir.path().to_path_buf(),
5473            dir.path().join(".mana"),
5474            &ctx,
5475        );
5476
5477        assert_eq!(options.model.as_deref(), Some("config-model"));
5478    }
5479
5480    #[test]
5481    fn native_run_runtime_info_reports_config_model() {
5482        let dir = tempfile::tempdir().unwrap();
5483        let (mut ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
5484        let config = crate::config::Config {
5485            model: Some("config-model".to_string()),
5486            ..Default::default()
5487        };
5488        ctx.config = Arc::new(config);
5489
5490        let runtime = runtime_info_for_run(&native_run_params_for_test(), &ctx);
5491
5492        assert_eq!(runtime.direct_agent.as_deref(), Some("imp"));
5493        assert_eq!(runtime.model.as_deref(), Some("config-model"));
5494    }
5495
5496    #[test]
5497    fn mana_run_state_persists_material_events() {
5498        let mut store = ManaRunStore::default();
5499        let run_id = store.start_run("all ready units".to_string(), &native_run_params_for_test());
5500        let before = store.snapshot(Some(&run_id)).unwrap().last_event_at_ms;
5501
5502        store.update_with_event(
5503            &run_id,
5504            &mana::stream::StreamEvent::UnitTool {
5505                id: "1".to_string(),
5506                tool_name: "read".to_string(),
5507                tool_count: 1,
5508                file_path: Some("src/lib.rs".to_string()),
5509            },
5510        );
5511
5512        let state = store.snapshot(Some(&run_id)).unwrap();
5513        assert_eq!(state.event_count, 1);
5514        assert!(state.last_event_at_ms >= before);
5515        assert!(state.log_lines.iter().any(|line| line.contains("#1 read")));
5516    }
5517
5518    #[test]
5519    fn mana_run_state_marks_stale_running_runs_interrupted() {
5520        let mut store = ManaRunStore::default();
5521        let run_id = store.start_run("all ready units".to_string(), &native_run_params_for_test());
5522        {
5523            let run = store
5524                .runs
5525                .iter_mut()
5526                .find(|run| run.run_id == run_id)
5527                .unwrap();
5528            run.status = "running".to_string();
5529            run.last_event_at_ms = unix_time_ms().saturating_sub(INTERRUPTED_RUN_STALE_MS + 1_000);
5530        }
5531
5532        store.classify_stale_unfinished_runs();
5533
5534        let state = store.snapshot(Some(&run_id)).unwrap();
5535        assert_eq!(state.status, "interrupted");
5536        assert!(state.error.as_deref().unwrap_or_default().contains("stale"));
5537        let output = run_state_output(&state);
5538        let text = output.text_content().unwrap_or_default();
5539        assert!(text.contains("Interrupted:"));
5540        assert_eq!(
5541            output.details["recovery"]["stale_workers"][0]["run_id"],
5542            run_id
5543        );
5544        assert!(output.details["recovery"]["next_actions"]
5545            .as_array()
5546            .unwrap()
5547            .iter()
5548            .any(|action| action
5549                .as_str()
5550                .unwrap_or_default()
5551                .contains("interrupted/stale")));
5552    }
5553}