Skip to main content

hematite/agent/
conversation.rs

1use crate::agent::architecture_summary::{
2    build_architecture_overview_answer, prune_architecture_trace_batch,
3    prune_authoritative_tool_batch, prune_read_only_context_bloat_batch,
4    prune_redirected_shell_batch, summarize_runtime_trace_output,
5};
6use crate::agent::direct_answers::{
7    build_about_answer, build_architect_session_reset_plan, build_authorization_policy_answer,
8    build_gemma_native_answer, build_gemma_native_settings_answer, build_identity_answer,
9    build_language_capability_answer, build_mcp_lifecycle_answer, build_product_surface_answer,
10    build_reasoning_split_answer, build_recovery_recipes_answer, build_session_memory_answer,
11    build_session_reset_semantics_answer, build_tool_classes_answer,
12    build_tool_registry_ownership_answer, build_unsafe_workflow_pressure_answer,
13    build_verify_profiles_answer, build_workflow_modes_answer,
14};
15use crate::agent::inference::{
16    ChatMessage, InferenceEngine, InferenceEvent, MessageContent, OperatorCheckpointState,
17    ProviderRuntimeState, ToolCallFn, ToolDefinition, ToolFunction,
18};
19use crate::agent::policy::{
20    action_target_path, docs_edit_without_explicit_request, is_destructive_tool,
21    is_mcp_mutating_tool, is_mcp_workspace_read_tool, is_sovereign_path_request,
22    normalize_workspace_path,
23};
24use crate::agent::recovery_recipes::{
25    attempt_recovery, plan_recovery, preview_recovery_decision, RecoveryContext, RecoveryDecision,
26    RecoveryPlan, RecoveryScenario, RecoveryStep,
27};
28use crate::agent::routing::{
29    all_host_inspection_topics, classify_query_intent, is_capability_probe_tool,
30    is_scaffold_request, looks_like_mutation_request, needs_computation_sandbox,
31    preferred_host_inspection_topic, preferred_maintainer_workflow, preferred_workspace_workflow,
32    DirectAnswerKind, QueryIntentClass,
33};
34use crate::agent::tool_registry::dispatch_builtin_tool;
35// SystemPromptBuilder is no longer used — InferenceEngine::build_system_prompt() is canonical.
36use crate::agent::compaction::{self, CompactionConfig};
37use crate::ui::gpu_monitor::GpuState;
38
39use serde_json::Value;
40use std::sync::Arc;
41use tokio::sync::{mpsc, Mutex};
42// -- Session persistence -------------------------------------------------------
43
44#[derive(Clone, Debug, Default)]
45pub struct UserTurn {
46    pub text: String,
47    pub attached_document: Option<AttachedDocument>,
48    pub attached_image: Option<AttachedImage>,
49}
50
51#[derive(Clone, Debug)]
52pub struct AttachedDocument {
53    pub name: String,
54    pub content: String,
55}
56
57#[derive(Clone, Debug)]
58pub struct AttachedImage {
59    pub name: String,
60    pub path: String,
61}
62
63impl UserTurn {
64    pub fn text(text: impl Into<String>) -> Self {
65        Self {
66            text: text.into(),
67            attached_document: None,
68            attached_image: None,
69        }
70    }
71}
72
73#[derive(serde::Serialize, serde::Deserialize)]
74struct SavedSession {
75    running_summary: Option<String>,
76    #[serde(default)]
77    session_memory: crate::agent::compaction::SessionMemory,
78    /// Last user message from the previous session — shown as resume hint on startup.
79    #[serde(default)]
80    last_goal: Option<String>,
81    /// Number of real inference turns completed in the previous session.
82    #[serde(default)]
83    turn_count: u32,
84}
85
86/// Snapshot of the previous session, surfaced on startup when a workspace is
87/// resumed after a restart or crash.
88pub struct CheckpointResume {
89    pub last_goal: String,
90    pub turn_count: u32,
91    pub working_files: Vec<String>,
92    pub last_verify_ok: Option<bool>,
93}
94
95/// Load the prior-session checkpoint from `.hematite/session.json`.
96/// Returns `None` when there is no prior session or it has no real turns.
97pub fn load_checkpoint() -> Option<CheckpointResume> {
98    let path = session_path();
99    let data = std::fs::read_to_string(&path).ok()?;
100    let saved: SavedSession = serde_json::from_str(&data).ok()?;
101    let goal = saved.last_goal.filter(|g| !g.trim().is_empty())?;
102    if saved.turn_count == 0 {
103        return None;
104    }
105    let mut working_files: Vec<String> = saved
106        .session_memory
107        .working_set
108        .into_iter()
109        .take(4)
110        .collect();
111    working_files.sort();
112    let last_verify_ok = saved.session_memory.last_verification.map(|v| v.successful);
113    Some(CheckpointResume {
114        last_goal: goal,
115        turn_count: saved.turn_count,
116        working_files,
117        last_verify_ok,
118    })
119}
120
121#[derive(Default)]
122struct ActionGroundingState {
123    turn_index: u64,
124    observed_paths: std::collections::HashMap<String, u64>,
125    inspected_paths: std::collections::HashMap<String, u64>,
126    last_verify_build_turn: Option<u64>,
127    last_verify_build_ok: bool,
128    last_failed_build_paths: Vec<String>,
129    code_changed_since_verify: bool,
130    /// Track topics redirected from shell to inspect_host in the current turn to break loops.
131    redirected_host_inspection_topics: std::collections::HashMap<String, u64>,
132}
133
134struct PlanExecutionGuard {
135    flag: Arc<std::sync::atomic::AtomicBool>,
136}
137
138impl Drop for PlanExecutionGuard {
139    fn drop(&mut self) {
140        self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
141    }
142}
143
144struct PlanExecutionPassGuard {
145    depth: Arc<std::sync::atomic::AtomicUsize>,
146}
147
148impl Drop for PlanExecutionPassGuard {
149    fn drop(&mut self) {
150        self.depth.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub enum WorkflowMode {
156    #[default]
157    Auto,
158    Ask,
159    Code,
160    Architect,
161    ReadOnly,
162    /// Clean conversational mode — lighter prompt, no coding agent scaffolding,
163    /// tools available but not pushed. Vein RAG still runs for context.
164    Chat,
165    /// Teacher/guide mode — inspect the real machine state first, then walk the user
166    /// through the admin/config task as a grounded, numbered tutorial. Never executes
167    /// write operations itself; instructs the user to perform them manually.
168    Teach,
169}
170
171impl WorkflowMode {
172    fn label(self) -> &'static str {
173        match self {
174            WorkflowMode::Auto => "AUTO",
175            WorkflowMode::Ask => "ASK",
176            WorkflowMode::Code => "CODE",
177            WorkflowMode::Architect => "ARCHITECT",
178            WorkflowMode::ReadOnly => "READ-ONLY",
179            WorkflowMode::Chat => "CHAT",
180            WorkflowMode::Teach => "TEACH",
181        }
182    }
183
184    fn is_read_only(self) -> bool {
185        matches!(
186            self,
187            WorkflowMode::Ask
188                | WorkflowMode::Architect
189                | WorkflowMode::ReadOnly
190                | WorkflowMode::Teach
191        )
192    }
193
194    pub(crate) fn is_chat(self) -> bool {
195        matches!(self, WorkflowMode::Chat)
196    }
197}
198
199fn session_path() -> std::path::PathBuf {
200    if let Ok(overridden) = std::env::var("HEMATITE_SESSION_PATH") {
201        return std::path::PathBuf::from(overridden);
202    }
203    crate::tools::file_ops::hematite_dir().join("session.json")
204}
205
206fn load_session_data() -> (Option<String>, crate::agent::compaction::SessionMemory) {
207    let path = session_path();
208    if !path.exists() {
209        let mut memory = crate::agent::compaction::SessionMemory::default();
210        if let Some(plan) = crate::tools::plan::load_plan_handoff() {
211            memory.current_plan = Some(plan);
212        }
213        return (None, memory);
214    }
215    let Ok(data) = std::fs::read_to_string(&path) else {
216        let mut memory = crate::agent::compaction::SessionMemory::default();
217        if let Some(plan) = crate::tools::plan::load_plan_handoff() {
218            memory.current_plan = Some(plan);
219        }
220        return (None, memory);
221    };
222    let Ok(saved) = serde_json::from_str::<SavedSession>(&data) else {
223        let mut memory = crate::agent::compaction::SessionMemory::default();
224        if let Some(plan) = crate::tools::plan::load_plan_handoff() {
225            memory.current_plan = Some(plan);
226        }
227        return (None, memory);
228    };
229    let mut memory = saved.session_memory;
230    if memory
231        .current_plan
232        .as_ref()
233        .map(|plan| plan.has_signal())
234        .unwrap_or(false)
235        == false
236    {
237        if let Some(plan) = crate::tools::plan::load_plan_handoff() {
238            memory.current_plan = Some(plan);
239        }
240    }
241    (saved.running_summary, memory)
242}
243
244#[derive(Clone)]
245struct SovereignTeleportHandoff {
246    root: String,
247    plan: crate::tools::plan::PlanHandoff,
248}
249
250fn reset_task_files() {
251    let hdir = crate::tools::file_ops::hematite_dir();
252    let root = crate::tools::file_ops::workspace_root();
253    let _ = std::fs::remove_file(hdir.join("TASK.md"));
254    let _ = std::fs::remove_file(hdir.join("PLAN.md"));
255    let _ = std::fs::remove_file(hdir.join("WALKTHROUGH.md"));
256    let _ = std::fs::remove_file(root.join(".github").join("WALKTHROUGH.md"));
257    let _ = std::fs::write(hdir.join("TASK.md"), "");
258    let _ = std::fs::write(hdir.join("PLAN.md"), "");
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262struct TaskChecklistProgress {
263    total: usize,
264    completed: usize,
265    remaining: usize,
266}
267
268impl TaskChecklistProgress {
269    fn has_open_items(self) -> bool {
270        self.remaining > 0
271    }
272}
273
274fn task_status_path() -> std::path::PathBuf {
275    crate::tools::file_ops::hematite_dir().join("TASK.md")
276}
277
278fn parse_task_checklist_progress(input: &str) -> TaskChecklistProgress {
279    let mut progress = TaskChecklistProgress::default();
280
281    for line in input.lines() {
282        let trimmed = line.trim_start();
283        let candidate = trimmed
284            .strip_prefix("- ")
285            .or_else(|| trimmed.strip_prefix("* "))
286            .or_else(|| trimmed.strip_prefix("+ "))
287            .unwrap_or(trimmed);
288
289        let state = if candidate.starts_with("[x]") || candidate.starts_with("[X]") {
290            Some(true)
291        } else if candidate.starts_with("[ ]") {
292            Some(false)
293        } else {
294            None
295        };
296
297        if let Some(completed) = state {
298            progress.total += 1;
299            if completed {
300                progress.completed += 1;
301            }
302        }
303    }
304
305    progress.remaining = progress.total.saturating_sub(progress.completed);
306    progress
307}
308
309fn read_task_checklist_progress() -> Option<TaskChecklistProgress> {
310    let content = std::fs::read_to_string(task_status_path()).ok()?;
311    Some(parse_task_checklist_progress(&content))
312}
313
314fn plan_execution_sidecar_paths() -> Vec<String> {
315    let hdir = crate::tools::file_ops::hematite_dir();
316    ["TASK.md", "PLAN.md", "WALKTHROUGH.md"]
317        .iter()
318        .map(|name| normalize_workspace_path(hdir.join(name).to_string_lossy().as_ref()))
319        .collect()
320}
321
322fn merge_plan_allowed_paths(target_files: &[String]) -> Vec<String> {
323    let mut allowed = std::collections::BTreeSet::new();
324    for path in target_files {
325        allowed.insert(normalize_workspace_path(path));
326    }
327    for path in plan_execution_sidecar_paths() {
328        allowed.insert(path);
329    }
330    allowed.into_iter().collect()
331}
332
333fn should_continue_plan_execution(
334    current_pass: usize,
335    before: Option<TaskChecklistProgress>,
336    after: Option<TaskChecklistProgress>,
337    mutated_paths: &std::collections::BTreeSet<String>,
338) -> bool {
339    const MAX_AUTONOMOUS_PLAN_PASSES: usize = 6;
340
341    if current_pass >= MAX_AUTONOMOUS_PLAN_PASSES {
342        return false;
343    }
344
345    let Some(after) = after else {
346        return false;
347    };
348    if !after.has_open_items() {
349        return false;
350    }
351
352    match before {
353        Some(before) if before.total > 0 => {
354            after.completed > before.completed || after.remaining < before.remaining
355        }
356        Some(before) => after.total > before.total || !mutated_paths.is_empty(),
357        None => !mutated_paths.is_empty(),
358    }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362struct AutoVerificationOutcome {
363    ok: bool,
364    summary: String,
365}
366
367fn should_run_website_validation(
368    contract: Option<&crate::agent::workspace_profile::RuntimeContract>,
369    mutated_paths: &std::collections::BTreeSet<String>,
370) -> bool {
371    let Some(contract) = contract else {
372        return false;
373    };
374    if contract.loop_family != "website" {
375        return false;
376    }
377    if mutated_paths.is_empty() {
378        return true;
379    }
380    mutated_paths.iter().any(|path| {
381        let normalized = path.replace('\\', "/").to_ascii_lowercase();
382        normalized.ends_with(".html")
383            || normalized.ends_with(".css")
384            || normalized.ends_with(".js")
385            || normalized.ends_with(".jsx")
386            || normalized.ends_with(".ts")
387            || normalized.ends_with(".tsx")
388            || normalized.ends_with(".mdx")
389            || normalized.ends_with(".vue")
390            || normalized.ends_with(".svelte")
391            || normalized.ends_with("package.json")
392            || normalized.starts_with("public/")
393            || normalized.starts_with("static/")
394            || normalized.starts_with("pages/")
395            || normalized.starts_with("app/")
396            || normalized.starts_with("src/pages/")
397            || normalized.starts_with("src/app/")
398    })
399}
400
401fn is_repeat_guard_exempt_tool_call(tool_name: &str, args: &Value) -> bool {
402    if matches!(tool_name, "verify_build" | "git_commit" | "git_push") {
403        return true;
404    }
405    tool_name == "run_workspace_workflow"
406        && matches!(
407            args.get("workflow").and_then(|value| value.as_str()),
408            Some("website_probe" | "website_validate" | "website_status")
409        )
410}
411
412fn should_run_contract_verification_workflow(
413    contract: Option<&crate::agent::workspace_profile::RuntimeContract>,
414    workflow: &str,
415    mutated_paths: &std::collections::BTreeSet<String>,
416) -> bool {
417    // Standard workflows always run if listed (they are already 'cheap').
418    if matches!(workflow, "build" | "test" | "lint") {
419        return true;
420    }
421
422    match workflow {
423        "website_validate" => should_run_website_validation(contract, mutated_paths),
424        _ => true,
425    }
426}
427
428fn build_continue_plan_execution_prompt(progress: TaskChecklistProgress) -> String {
429    format!(
430        "Continue implementing the current plan. Read `.hematite/TASK.md` first, focus on the next unchecked items, and keep working until the checklist is complete or you hit one concrete blocker. There are currently {} unchecked checklist item(s) remaining.",
431        progress.remaining
432    )
433}
434
435fn purge_persistent_memory() {
436    let mem_dir = crate::tools::file_ops::hematite_dir().join("memories");
437    if mem_dir.exists() {
438        let _ = std::fs::remove_dir_all(&mem_dir);
439        let _ = std::fs::create_dir_all(&mem_dir);
440    }
441
442    let log_dir = crate::tools::file_ops::hematite_dir().join("logs");
443    if log_dir.exists() {
444        if let Ok(entries) = std::fs::read_dir(&log_dir) {
445            for entry in entries.flatten() {
446                let _ = std::fs::write(entry.path(), "");
447            }
448        }
449    }
450}
451
452fn apply_turn_attachments(user_turn: &UserTurn, prompt: &str) -> String {
453    let mut out = prompt.trim().to_string();
454    if let Some(doc) = user_turn.attached_document.as_ref() {
455        out = format!(
456            "[Attached document: {}]\n\n{}\n\n---\n\n{}",
457            doc.name, doc.content, out
458        );
459    }
460    if let Some(image) = user_turn.attached_image.as_ref() {
461        out = if out.is_empty() {
462            format!("[Attached image: {}]", image.name)
463        } else {
464            format!("[Attached image: {}]\n\n{}", image.name, out)
465        };
466    }
467    // Auto-inject @file mentions — parse @<path> tokens and prepend file content
468    // so the model can edit immediately without a read_file round-trip.
469    out = inject_at_file_mentions(&out);
470    out
471}
472
473/// Parse `@<path>` tokens from the user prompt, read each file, and prepend its
474/// content as inline context. Tokens that don't resolve to readable files are
475/// left as-is so the model can still call read_file if needed.
476fn inject_at_file_mentions(prompt: &str) -> String {
477    // Quick bail — no @ present
478    if !prompt.contains('@') {
479        return prompt.to_string();
480    }
481    let cwd = match std::env::current_dir() {
482        Ok(d) => d,
483        Err(_) => return prompt.to_string(),
484    };
485
486    let mut injected = Vec::new();
487    // Split on whitespace+punctuation boundaries but keep the original prompt intact
488    for token in prompt.split_whitespace() {
489        let raw = token.trim_start_matches('@');
490        if !token.starts_with('@') || raw.is_empty() {
491            continue;
492        }
493        // Strip trailing punctuation that isn't part of a path
494        let path_str =
495            raw.trim_end_matches(|c: char| matches!(c, ',' | '.' | ':' | ';' | '!' | '?'));
496        if path_str.is_empty() {
497            continue;
498        }
499        let candidate = cwd.join(path_str);
500        if candidate.is_file() {
501            match std::fs::read_to_string(&candidate) {
502                Ok(content) if !content.is_empty() => {
503                    // Cap at 32 KB so a huge file doesn't blow the context
504                    const CAP: usize = 32 * 1024;
505                    let body = if content.len() > CAP {
506                        format!(
507                            "{}\n... [truncated — file is large, use read_file for the rest]",
508                            &content[..CAP]
509                        )
510                    } else {
511                        content
512                    };
513                    injected.push(format!("[File: {}]\n```\n{}\n```", path_str, body.trim()));
514                }
515                _ => {}
516            }
517        }
518    }
519
520    if injected.is_empty() {
521        return prompt.to_string();
522    }
523    // Prepend injected file blocks before the user message
524    format!("{}\n\n---\n\n{}", injected.join("\n\n"), prompt)
525}
526
527/// After a successful edit on `path`, replace large stale read_file / inspect_lines results
528/// for that same path in history with a compact stub. The file just changed so old content
529/// is both wrong and wasteful — keeping it burns context the model needs for the next edit.
530///
531/// We leave the two most recent messages untouched so any read that was part of the current
532/// edit cycle stays visible (the model may still reference it for adjacent edits).
533fn compact_stale_reads(history: &mut Vec<ChatMessage>, path: &str) {
534    const MIN_SIZE_TO_COMPACT: usize = 800;
535    let stub = "[prior read_file content compacted — file was edited; use read_file to reload]";
536    let normalized = normalize_workspace_path(path);
537    let safe_tail = history.len().saturating_sub(2);
538    for msg in history[..safe_tail].iter_mut() {
539        if msg.role != "tool" {
540            continue;
541        }
542        let is_read_tool = matches!(
543            msg.name.as_deref(),
544            Some("read_file") | Some("inspect_lines")
545        );
546        if !is_read_tool {
547            continue;
548        }
549        let content = match &msg.content {
550            crate::agent::inference::MessageContent::Text(s) => s.clone(),
551            _ => continue,
552        };
553        if content.len() < MIN_SIZE_TO_COMPACT {
554            continue;
555        }
556        // Match on normalized path or the raw path appearing anywhere in the content
557        if content.contains(&normalized) || content.contains(path) {
558            msg.content = crate::agent::inference::MessageContent::Text(stub.to_string());
559        }
560    }
561}
562
563/// Read up to `max_lines` lines from a file with line numbers, for edit-fail auto-recovery.
564/// Returns a placeholder string if the file cannot be read.
565fn read_file_preview_for_retry(path: &str, max_lines: usize) -> String {
566    let content = match std::fs::read_to_string(path) {
567        Ok(c) => c.replace("\r\n", "\n"),
568        Err(e) => return format!("[could not read {path}: {e}]"),
569    };
570    let total = content.lines().count();
571    let lines: String = content
572        .lines()
573        .enumerate()
574        .take(max_lines)
575        .map(|(i, line)| format!("{:>4}  {}", i + 1, line))
576        .collect::<Vec<_>>()
577        .join("\n");
578    if total > max_lines {
579        format!(
580            "{lines}\n... [{} more lines — use inspect_lines to see the rest]",
581            total - max_lines
582        )
583    } else {
584        lines
585    }
586}
587
588fn transcript_user_turn_text(user_turn: &UserTurn, prompt: &str) -> String {
589    let mut prefixes = Vec::new();
590    if let Some(doc) = user_turn.attached_document.as_ref() {
591        prefixes.push(format!("[Attached document: {}]", doc.name));
592    }
593    if let Some(image) = user_turn.attached_image.as_ref() {
594        prefixes.push(format!("[Attached image: {}]", image.name));
595    }
596    if prefixes.is_empty() {
597        prompt.to_string()
598    } else if prompt.trim().is_empty() {
599        prefixes.join("\n")
600    } else {
601        format!("{}\n{}", prefixes.join("\n"), prompt)
602    }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606enum RuntimeFailureClass {
607    ContextWindow,
608    ProviderDegraded,
609    ToolArgMalformed,
610    ToolPolicyBlocked,
611    ToolLoop,
612    VerificationFailed,
613    EmptyModelResponse,
614    Unknown,
615}
616
617impl RuntimeFailureClass {
618    fn tag(self) -> &'static str {
619        match self {
620            RuntimeFailureClass::ContextWindow => "context_window",
621            RuntimeFailureClass::ProviderDegraded => "provider_degraded",
622            RuntimeFailureClass::ToolArgMalformed => "tool_arg_malformed",
623            RuntimeFailureClass::ToolPolicyBlocked => "tool_policy_blocked",
624            RuntimeFailureClass::ToolLoop => "tool_loop",
625            RuntimeFailureClass::VerificationFailed => "verification_failed",
626            RuntimeFailureClass::EmptyModelResponse => "empty_model_response",
627            RuntimeFailureClass::Unknown => "unknown",
628        }
629    }
630
631    fn operator_guidance(self) -> &'static str {
632        match self {
633            RuntimeFailureClass::ContextWindow => {
634                "Narrow the request, compact the session, or preserve grounded tool output instead of restyling it. If LM Studio reports a smaller live n_ctx than Hematite expected, reload or re-detect the model budget before retrying."
635            }
636            RuntimeFailureClass::ProviderDegraded => {
637                "Retry once automatically, then narrow the turn or restart LM Studio if it persists."
638            }
639            RuntimeFailureClass::ToolArgMalformed => {
640                "Retry with repaired or narrower tool arguments instead of repeating the same malformed call."
641            }
642            RuntimeFailureClass::ToolPolicyBlocked => {
643                "Stay inside the allowed workflow or switch modes before retrying."
644            }
645            RuntimeFailureClass::ToolLoop => {
646                "Stop repeating the same failing tool pattern and switch to a narrower recovery step."
647            }
648            RuntimeFailureClass::VerificationFailed => {
649                "Fix the build or test failure before treating the task as complete."
650            }
651            RuntimeFailureClass::EmptyModelResponse => {
652                "Retry once automatically, then narrow the turn or restart LM Studio if the model keeps returning nothing."
653            }
654            RuntimeFailureClass::Unknown => {
655                "Inspect the latest grounded tool results or provider status before retrying."
656            }
657        }
658    }
659}
660
661fn classify_runtime_failure(detail: &str) -> RuntimeFailureClass {
662    let lower = detail.to_ascii_lowercase();
663    if lower.contains("context_window_blocked")
664        || lower.contains("context ceiling reached")
665        || lower.contains("exceeds the")
666        || ((lower.contains("n_keep") && lower.contains("n_ctx"))
667            || lower.contains("context length")
668            || lower.contains("keep from the initial prompt")
669            || lower.contains("prompt is greater than the context length"))
670    {
671        RuntimeFailureClass::ContextWindow
672    } else if lower.contains("empty response from model")
673        || lower.contains("model returned an empty response")
674    {
675        RuntimeFailureClass::EmptyModelResponse
676    } else if lower.contains("lm studio unreachable")
677        || lower.contains("lm studio error")
678        || lower.contains("request failed")
679        || lower.contains("response parse error")
680        || lower.contains("provider degraded")
681    {
682        RuntimeFailureClass::ProviderDegraded
683    } else if lower.contains("missing required argument")
684        || lower.contains("json repair failed")
685        || lower.contains("invalid pattern")
686        || lower.contains("invalid line range")
687    {
688        RuntimeFailureClass::ToolArgMalformed
689    } else if lower.contains("action blocked:")
690        || lower.contains("access denied")
691        || lower.contains("declined by user")
692    {
693        RuntimeFailureClass::ToolPolicyBlocked
694    } else if lower.contains("too many consecutive tool errors")
695        || lower.contains("repeated tool failures")
696        || lower.contains("stuck in a loop")
697    {
698        RuntimeFailureClass::ToolLoop
699    } else if lower.contains("build failed")
700        || lower.contains("verification failed")
701        || lower.contains("verify_build")
702    {
703        RuntimeFailureClass::VerificationFailed
704    } else {
705        RuntimeFailureClass::Unknown
706    }
707}
708
709fn format_runtime_failure(class: RuntimeFailureClass, detail: &str) -> String {
710    format!(
711        "[failure:{}] {} Detail: {}",
712        class.tag(),
713        class.operator_guidance(),
714        detail.trim()
715    )
716}
717
718fn provider_state_for_runtime_failure(class: RuntimeFailureClass) -> Option<ProviderRuntimeState> {
719    match class {
720        RuntimeFailureClass::ContextWindow => Some(ProviderRuntimeState::ContextWindow),
721        RuntimeFailureClass::ProviderDegraded => Some(ProviderRuntimeState::Degraded),
722        RuntimeFailureClass::EmptyModelResponse => Some(ProviderRuntimeState::EmptyResponse),
723        _ => None,
724    }
725}
726
727fn checkpoint_state_for_runtime_failure(
728    class: RuntimeFailureClass,
729) -> Option<OperatorCheckpointState> {
730    match class {
731        RuntimeFailureClass::ContextWindow => Some(OperatorCheckpointState::BlockedContextWindow),
732        RuntimeFailureClass::ToolPolicyBlocked => Some(OperatorCheckpointState::BlockedPolicy),
733        RuntimeFailureClass::ToolLoop => Some(OperatorCheckpointState::BlockedToolLoop),
734        RuntimeFailureClass::VerificationFailed => {
735            Some(OperatorCheckpointState::BlockedVerification)
736        }
737        _ => None,
738    }
739}
740
741fn compact_runtime_recovery_summary(class: RuntimeFailureClass) -> &'static str {
742    match class {
743        RuntimeFailureClass::ProviderDegraded => {
744            "LM Studio degraded during the turn; retrying once before surfacing a failure."
745        }
746        RuntimeFailureClass::EmptyModelResponse => {
747            "The model returned an empty reply; retrying once before surfacing a failure."
748        }
749        _ => "Runtime recovery in progress.",
750    }
751}
752
753fn checkpoint_summary_for_runtime_failure(class: RuntimeFailureClass) -> &'static str {
754    match class {
755        RuntimeFailureClass::ContextWindow => "Provider context ceiling confirmed.",
756        RuntimeFailureClass::ToolPolicyBlocked => "Policy blocked the current action.",
757        RuntimeFailureClass::ToolLoop => "Repeated failing tool pattern stopped.",
758        RuntimeFailureClass::VerificationFailed => "Verification failed; fix before continuing.",
759        _ => "Operator checkpoint updated.",
760    }
761}
762
763fn compact_runtime_failure_summary(class: RuntimeFailureClass) -> &'static str {
764    match class {
765        RuntimeFailureClass::ContextWindow => "LM context ceiling hit.",
766        RuntimeFailureClass::ProviderDegraded => {
767            "LM Studio degraded and did not recover cleanly; operator action is now required."
768        }
769        RuntimeFailureClass::EmptyModelResponse => {
770            "LM Studio returned an empty reply after recovery; operator action is now required."
771        }
772        RuntimeFailureClass::ToolLoop => {
773            "Repeated failing tool pattern detected; Hematite stopped the loop."
774        }
775        _ => "Runtime failure surfaced to the operator.",
776    }
777}
778
779fn should_retry_runtime_failure(class: RuntimeFailureClass) -> bool {
780    matches!(
781        class,
782        RuntimeFailureClass::ProviderDegraded | RuntimeFailureClass::EmptyModelResponse
783    )
784}
785
786fn recovery_scenario_for_runtime_failure(class: RuntimeFailureClass) -> Option<RecoveryScenario> {
787    match class {
788        RuntimeFailureClass::ContextWindow => Some(RecoveryScenario::ContextWindow),
789        RuntimeFailureClass::ProviderDegraded => Some(RecoveryScenario::ProviderDegraded),
790        RuntimeFailureClass::EmptyModelResponse => Some(RecoveryScenario::EmptyModelResponse),
791        RuntimeFailureClass::ToolPolicyBlocked => Some(RecoveryScenario::McpWorkspaceReadBlocked),
792        RuntimeFailureClass::ToolLoop => Some(RecoveryScenario::ToolLoop),
793        RuntimeFailureClass::VerificationFailed => Some(RecoveryScenario::VerificationFailed),
794        RuntimeFailureClass::ToolArgMalformed | RuntimeFailureClass::Unknown => None,
795    }
796}
797
798fn compact_recovery_plan_summary(plan: &RecoveryPlan) -> String {
799    format!(
800        "{} [{}]",
801        plan.recipe.scenario.label(),
802        plan.recipe.steps_summary()
803    )
804}
805
806fn compact_recovery_decision_summary(decision: &RecoveryDecision) -> String {
807    match decision {
808        RecoveryDecision::Attempt(plan) => compact_recovery_plan_summary(plan),
809        RecoveryDecision::Escalate {
810            recipe,
811            attempts_made,
812            ..
813        } => format!(
814            "{} escalated after {} / {} [{}]",
815            recipe.scenario.label(),
816            attempts_made,
817            recipe.max_attempts.max(1),
818            recipe.steps_summary()
819        ),
820    }
821}
822
823/// Parse file paths from cargo/compiler error output.
824/// Handles lines like `  --> src/foo/bar.rs:34:12` and `error: could not compile`.
825fn parse_failing_paths_from_build_output(output: &str) -> Vec<String> {
826    let root = crate::tools::file_ops::workspace_root();
827    let mut paths: Vec<String> = output
828        .lines()
829        .filter_map(|line| {
830            let trimmed = line.trim_start();
831            // Cargo error location: "--> path/to/file.rs:line:col"
832            let after_arrow = trimmed.strip_prefix("--> ")?;
833            let file_part = after_arrow.split(':').next()?;
834            if file_part.is_empty() || file_part.starts_with('<') {
835                return None;
836            }
837            let p = std::path::Path::new(file_part);
838            let resolved = if p.is_absolute() {
839                p.to_path_buf()
840            } else {
841                root.join(p)
842            };
843            Some(resolved.to_string_lossy().replace('\\', "/").to_lowercase())
844        })
845        .collect();
846    paths.sort();
847    paths.dedup();
848    paths
849}
850
851fn build_mode_redirect_answer(mode: WorkflowMode) -> String {
852    match mode {
853        WorkflowMode::Ask => "Workflow mode ASK is read-only. I can inspect the code, explain what should change, or review the target area, but I will not modify files here. Switch to `/code` to implement the change, or `/auto` to let Hematite choose.".to_string(),
854        WorkflowMode::Architect => "Workflow mode ARCHITECT is plan-first. I can inspect the code and design the implementation approach, but I will not mutate files until you explicitly switch to `/code` or ask me to implement.".to_string(),
855        WorkflowMode::ReadOnly => "Workflow mode READ-ONLY is a hard no-mutation mode. I can analyze, inspect, and explain, but I will not edit files, run mutating shell commands, or commit changes. Switch to `/code` or `/auto` if you want implementation.".to_string(),
856        WorkflowMode::Teach => "Workflow mode TEACH is a guided walkthrough mode. I will inspect the real state of your machine first, then give you a numbered step-by-step tutorial so you can perform the task yourself. I do not execute write operations in TEACH mode — I show you exactly how to do it.".to_string(),
857        _ => "Switch to `/code` or `/auto` to allow implementation.".to_string(),
858    }
859}
860
861fn architect_handoff_contract() -> &'static str {
862    "ARCHITECT OUTPUT CONTRACT:\n\
863Use a compact implementation handoff, not a process narrative.\n\
864Do not say \"the first step\" or describe what you are about to do.\n\
865After one or two read-only inspection tools at most, stop and answer.\n\
866For runtime wiring, reset behavior, or control-flow questions, prefer `trace_runtime_flow`.\n\
867Use these exact ASCII headings and keep each section short:\n\
868# Goal\n\
869# Target Files\n\
870# Ordered Steps\n\
871# Verification\n\
872# Risks\n\
873# Open Questions\n\
874Keep the whole handoff concise and implementation-oriented."
875}
876
877fn implement_current_plan_prompt() -> &'static str {
878    "Implement the current plan."
879}
880
881fn scaffold_protocol() -> &'static str {
882    "\n\n# SCAFFOLD MODE — PROJECT CREATION PROTOCOL\n\
883     The user wants a new project created. Your job is to build it completely, right now, without stopping.\n\
884     \n\
885     ## Autonomy rules\n\
886     - Build every file the project needs in one pass. Do NOT stop after one file and wait.\n\
887     - After writing each file, read it back to verify it is complete and not truncated.\n\
888     - Check cross-file consistency before finishing.\n\
889     - Once the project is coherent, runnable, and verified, STOP.\n\
890     - Mandatory Checklist Protocol: Whenever drafting a plan for a project scaffold, you MUST initialize a `.hematite/TASK.md` file with a granular `[ ]` checklist. Update it after every file mutation.\n\
891     - If only optional polish remains, present it as optional next steps instead of mutating more files.\n\
892     - Ask the user only when blocked by a real product decision, missing requirement, or risky/destructive choice.\n\
893     - Only surface results to the user once ALL files exist and the project is immediately runnable.\n\
894     - Final delivery must sound like a human engineer closeout: stack chosen, what was built, what was verified, and what remains optional.\n\
895     \n\
896     ## Infer the stack from context\n\
897     If the user gives only a vague request (\"make me a website\", \"build me a tool\"), pick the most\n\
898     sensible minimal stack and state your choice before creating files. Do not ask permission — choose and build.\n\
899     For scaffold/project-creation turns, do NOT use `run_workspace_workflow` unless the user explicitly asks you to run an existing build, test, lint, package script, or repo command.\n\
900     Default choices: website → static HTML+CSS+JS; CLI tool → Rust (clap) if Rust project, Python (argparse/click) otherwise;\n\
901     API → FastAPI (Python) or Express (Node); web app with state → React (Vite).\n\
902     \n\
903     ## Stack file structures\n\
904     \n\
905     **Static HTML site / landing page:**\n\
906     index.html (semantic: header/nav/main/footer, doctype, meta charset/viewport, linked CSS+JS),\n\
907     style.css (CSS variables, mobile-first, grid/flexbox, @media breakpoints, hover/focus states),\n\
908     script.js (DOMContentLoaded guard, smooth scroll, no console.log left in), README.md\n\
909     \n\
910     **React (Vite):**\n\
911     package.json (scripts: dev/build/preview, deps: react react-dom, devDeps: vite @vitejs/plugin-react),\n\
912     vite.config.js, index.html (root div), src/main.jsx, src/App.jsx, src/App.css, src/index.css, .gitignore, README.md\n\
913     \n\
914     **Next.js (App Router):**\n\
915     package.json (next react react-dom, scripts: dev/build/start),\n\
916     next.config.js, tsconfig.json, app/layout.tsx, app/page.tsx, app/globals.css, public/.gitkeep, .gitignore, README.md\n\
917     \n\
918     **Vue 3 (Vite):**\n\
919     package.json (vue, vite, @vitejs/plugin-vue),\n\
920     vite.config.js, index.html, src/main.js, src/App.vue, src/components/.gitkeep, .gitignore, README.md\n\
921     \n\
922     **SvelteKit:**\n\
923     package.json (@sveltejs/kit, svelte, vite, @sveltejs/adapter-auto),\n\
924     svelte.config.js, vite.config.js, src/routes/+page.svelte, src/app.html, static/.gitkeep, .gitignore, README.md\n\
925     \n\
926     **Express.js API:**\n\
927     package.json (express, cors, dotenv; nodemon as devDep; scripts: start/dev),\n\
928     src/index.js (listen + middleware), src/routes/index.js, src/middleware/error.js, .env.example, .gitignore, README.md\n\
929     \n\
930     **FastAPI (Python):**\n\
931     requirements.txt (fastapi, uvicorn[standard], pydantic),\n\
932     main.py (app = FastAPI(), include_router, uvicorn.run guard),\n\
933     app/__init__.py, app/routers/items.py, app/models.py, .gitignore (venv/ __pycache__/ .env), README.md\n\
934     \n\
935     **Flask (Python):**\n\
936     requirements.txt (flask, python-dotenv),\n\
937     app.py or app/__init__.py, app/routes.py, templates/base.html, static/style.css, .gitignore, README.md\n\
938     \n\
939     **Django:**\n\
940     requirements.txt, manage.py, project/settings.py, project/urls.py, project/wsgi.py,\n\
941     app/models.py, app/views.py, app/urls.py, templates/base.html, .gitignore, README.md\n\
942     \n\
943     **Python CLI (click or argparse):**\n\
944     pyproject.toml (name, version, [project.scripts] entry point) or setup.py,\n\
945     src/<name>/__init__.py, src/<name>/cli.py (click group or argparse main), src/<name>/core.py,\n\
946     README.md, .gitignore (__pycache__/ dist/ *.egg-info venv/)\n\
947     \n\
948     **Python package/library:**\n\
949     pyproject.toml (PEP 517/518, hatchling or setuptools), src/<name>/__init__.py, src/<name>/core.py,\n\
950     tests/__init__.py, tests/test_core.py, README.md, .gitignore\n\
951     \n\
952     **Rust CLI (clap):**\n\
953     Cargo.toml (name, edition=2021, clap with derive feature),\n\
954     src/main.rs (Cli struct with #[derive(Parser)], fn main), src/cli.rs (subcommands if needed),\n\
955     README.md, .gitignore (target/)\n\
956     \n\
957     **Rust library:**\n\
958     Cargo.toml ([lib], edition=2021), src/lib.rs (pub mod, pub fn, doc comments),\n\
959     tests/integration_test.rs, README.md, .gitignore\n\
960     \n\
961     **Go project / CLI:**\n\
962     go.mod (module <name>, go 1.21), main.go (package main, func main),\n\
963     cmd/<name>/main.go if CLI, internal/core/core.go for logic,\n\
964     README.md, .gitignore (bin/ *.exe)\n\
965     \n\
966     **C++ project (CMake):**\n\
967     CMakeLists.txt (cmake_minimum_required, project, add_executable, set C++17/20),\n\
968     src/main.cpp, include/<name>.h, src/<name>.cpp,\n\
969     README.md, .gitignore (build/ *.o *.exe CMakeCache.txt)\n\
970     \n\
971     **Node.js TypeScript API:**\n\
972     package.json (express @types/express typescript ts-node nodemon; scripts: build/dev/start),\n\
973     tsconfig.json (strict, esModuleInterop, outDir: dist), src/index.ts, src/routes/index.ts,\n\
974     .env.example, .gitignore, README.md\n\
975     \n\
976     ## File quality rules\n\
977     - Every file must be complete — no truncation, no placeholder comments like \"add logic here\"\n\
978     - package.json: name, version, scripts, all deps explicit\n\
979     - HTML: doctype, charset, viewport, title, all linked CSS/JS, semantic structure\n\
980     - CSS: consistent class names matching HTML exactly, responsive, variables for colors/spacing\n\
981     - .gitignore: cover node_modules/, dist/, .env, __pycache__/, target/, venv/, build/ as appropriate\n\
982     - Rust Cargo.toml: edition = \"2021\", all used crates declared\n\
983     - Go go.mod: module path and go version declared\n\
984     - C++ CMakeLists.txt: cmake version, project name, standard, all source files listed\n\
985     \n\
986     ## After scaffolding — required wrap-up\n\
987     1. List every file created with a one-line description of what it does\n\
988     2. Give the exact command(s) to install dependencies and run the project\n\
989     3. Tell the user they can type `/cd <project-folder>` to teleport into the new project\n\
990     4. Ask what they'd like to work on next — offer 2-3 specific suggestions relevant to the stack\n\
991        (e.g. \"Want me to add routing? Set up authentication? Add a dark mode toggle? Or should we improve the design?\")\n\
992     5. Stay engaged — you are their coding partner, not a one-shot file generator\n"
993}
994
995fn looks_like_static_site_request(input: &str) -> bool {
996    let lower = input.to_ascii_lowercase();
997    (lower.contains("website") || lower.contains("landing page") || lower.contains("web page"))
998        && (lower.contains("html")
999            || lower.contains("css")
1000            || lower.contains("javascript")
1001            || lower.contains("js")
1002            || !lower.contains("react"))
1003}
1004
1005fn sanitize_project_folder_name(raw: &str) -> String {
1006    let trimmed = raw
1007        .trim()
1008        .trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '.' | ',' | ':' | ';'));
1009    let mut out = String::new();
1010    for ch in trimmed.chars() {
1011        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ' ') {
1012            out.push(ch);
1013        } else {
1014            out.push('_');
1015        }
1016    }
1017    let cleaned = out.trim().replace(' ', "_");
1018    if cleaned.is_empty() {
1019        "hematite_project".to_string()
1020    } else {
1021        cleaned
1022    }
1023}
1024
1025fn extract_named_folder(lower: &str) -> Option<String> {
1026    for marker in [" named ", " called "] {
1027        if let Some(idx) = lower.find(marker) {
1028            let rest = &lower[idx + marker.len()..];
1029            let name = rest
1030                .split(|c: char| {
1031                    c.is_whitespace() || matches!(c, ',' | '.' | ':' | ';' | '!' | '?')
1032                })
1033                .next()
1034                .unwrap_or("")
1035                .trim();
1036            if !name.is_empty() {
1037                return Some(sanitize_project_folder_name(name));
1038            }
1039        }
1040    }
1041    None
1042}
1043
1044fn extract_sovereign_scaffold_root(user_input: &str) -> Option<std::path::PathBuf> {
1045    let lower = user_input.to_ascii_lowercase();
1046    let folder_name = extract_named_folder(&lower)?;
1047
1048    let base = if lower.contains("desktop") {
1049        dirs::desktop_dir()
1050    } else if lower.contains("download") {
1051        dirs::download_dir()
1052    } else if lower.contains("document") || lower.contains("docs") {
1053        dirs::document_dir()
1054    } else {
1055        None
1056    }?;
1057
1058    Some(base.join(folder_name))
1059}
1060
1061fn default_sovereign_scaffold_targets(user_input: &str) -> std::collections::BTreeSet<String> {
1062    let mut targets = std::collections::BTreeSet::new();
1063    if looks_like_static_site_request(user_input) {
1064        targets.insert("index.html".to_string());
1065        targets.insert("style.css".to_string());
1066        targets.insert("script.js".to_string());
1067    }
1068    targets
1069}
1070
1071fn seed_sovereign_scaffold_files(
1072    root: &std::path::Path,
1073    targets: &std::collections::BTreeSet<String>,
1074) -> Result<(), String> {
1075    for relative in targets {
1076        let path = root.join(relative);
1077        if let Some(parent) = path.parent() {
1078            std::fs::create_dir_all(parent)
1079                .map_err(|e| format!("Failed to create scaffold parent directory: {e}"))?;
1080        }
1081        if !path.exists() {
1082            std::fs::write(&path, "")
1083                .map_err(|e| format!("Failed to seed scaffold file {}: {e}", path.display()))?;
1084        }
1085    }
1086    Ok(())
1087}
1088
1089fn write_sovereign_handoff_markdown(
1090    root: &std::path::Path,
1091    user_input: &str,
1092    plan: &crate::tools::plan::PlanHandoff,
1093) -> Result<(), String> {
1094    let handoff_path = root.join("HEMATITE_HANDOFF.md");
1095    let content = format!(
1096        "# Hematite Handoff\n\n\
1097         Original request:\n\
1098         - {}\n\n\
1099         This project root was pre-created by Hematite before teleport.\n\
1100         The next session should resume from the local `.hematite/PLAN.md` handoff and continue implementation here.\n\n\
1101         ## Planned Target Files\n{}\n\
1102         ## Verification\n- {}\n",
1103        user_input.trim(),
1104        if plan.target_files.is_empty() {
1105            "- project files to be created in the resumed session\n".to_string()
1106        } else {
1107            plan.target_files
1108                .iter()
1109                .map(|path| format!("- {path}\n"))
1110                .collect::<String>()
1111        },
1112        plan.verification.trim()
1113    );
1114    std::fs::write(&handoff_path, content)
1115        .map_err(|e| format!("Failed to write handoff markdown: {e}"))
1116}
1117
1118fn build_sovereign_scaffold_handoff(
1119    user_input: &str,
1120    target_files: &std::collections::BTreeSet<String>,
1121) -> crate::tools::plan::PlanHandoff {
1122    let mut steps = vec![
1123        "Read the scaffolded files in this root before changing them so the resumed session stays grounded in the actual generated content.".to_string(),
1124        "Finish the implementation inside this sovereign project root only; do not reason from the old workspace or unrelated ./src context.".to_string(),
1125        "Keep the file set coherent instead of thrashing cosmetics; once the project is runnable or internally consistent, stop and summarize like a human engineer.".to_string(),
1126    ];
1127    let verification = if looks_like_static_site_request(user_input) {
1128        steps.insert(
1129            1,
1130            "Make sure index.html, style.css, and script.js stay linked correctly and that the layout remains responsive on desktop and mobile.".to_string(),
1131        );
1132        "Open and inspect the generated front-end files in this root, confirm cross-file links are valid, and verify the page is coherent and responsive without using repo-root workflows.".to_string()
1133    } else {
1134        "Use only project-appropriate verification scoped to this root. Avoid unrelated repo workflows; verify the generated files are internally consistent before stopping.".to_string()
1135    };
1136
1137    crate::tools::plan::PlanHandoff {
1138        goal: format!(
1139            "Continue the sovereign scaffold task in this new project root: {}",
1140            user_input.trim()
1141        ),
1142        target_files: target_files.iter().cloned().collect(),
1143        ordered_steps: steps,
1144        verification,
1145        risks: vec![
1146            "Do not drift back into the originating workspace or unrelated ./src context."
1147                .to_string(),
1148            "Avoid endless UI polish loops once the generated project is already coherent."
1149                .to_string(),
1150        ],
1151        open_questions: Vec::new(),
1152    }
1153}
1154
1155fn architect_handoff_operator_note(plan: &crate::tools::plan::PlanHandoff) -> String {
1156    format!(
1157        "Implementation handoff saved to `.hematite/PLAN.md`.\nNext step: run `/implement-plan` to execute it in `/code`, or use `/code {}` directly.\nPlan: {}",
1158        implement_current_plan_prompt().to_ascii_lowercase(),
1159        plan.summary_line()
1160    )
1161}
1162
1163fn is_current_plan_execution_request(user_input: &str) -> bool {
1164    let lower = user_input.trim().to_ascii_lowercase();
1165    lower == "/implement-plan"
1166        || lower == implement_current_plan_prompt().to_ascii_lowercase()
1167        || lower
1168            == implement_current_plan_prompt()
1169                .trim_end_matches('.')
1170                .to_ascii_lowercase()
1171        || lower.contains("implement the current plan")
1172}
1173
1174fn is_plan_scoped_tool(name: &str) -> bool {
1175    crate::agent::inference::tool_metadata_for_name(name).plan_scope
1176}
1177
1178fn is_current_plan_irrelevant_tool(name: &str) -> bool {
1179    !crate::agent::inference::tool_metadata_for_name(name).plan_scope
1180}
1181
1182fn is_non_mutating_plan_step_tool(name: &str) -> bool {
1183    let metadata = crate::agent::inference::tool_metadata_for_name(name);
1184    metadata.plan_scope && !metadata.mutates_workspace
1185}
1186
1187fn parse_inline_workflow_prompt(user_input: &str) -> Option<(WorkflowMode, &str)> {
1188    let trimmed = user_input.trim();
1189    for (prefix, mode) in [
1190        ("/ask", WorkflowMode::Ask),
1191        ("/code", WorkflowMode::Code),
1192        ("/architect", WorkflowMode::Architect),
1193        ("/read-only", WorkflowMode::ReadOnly),
1194        ("/auto", WorkflowMode::Auto),
1195        ("/teach", WorkflowMode::Teach),
1196    ] {
1197        if let Some(rest) = trimmed.strip_prefix(prefix) {
1198            let rest = rest.trim();
1199            if !rest.is_empty() {
1200                return Some((mode, rest));
1201            }
1202        }
1203    }
1204    None
1205}
1206
1207// Tool catalogue
1208
1209/// Returns the full set of tools exposed to the model.
1210pub fn get_tools() -> Vec<ToolDefinition> {
1211    crate::agent::tool_registry::get_tools()
1212}
1213
1214fn is_natural_language_hallucination(input: &str) -> bool {
1215    let lower = input.to_lowercase();
1216    let words = lower.split_whitespace().collect::<Vec<_>>();
1217
1218    // 1. Sentences starting with conversational phrases
1219    if words.is_empty() {
1220        return false;
1221    }
1222    let first = words[0];
1223    if [
1224        "make", "create", "i", "can", "please", "we", "let's", "go", "execute", "run", "how",
1225    ]
1226    .contains(&first)
1227    {
1228        // If it's more than 2 words, it's likely a sentence, not a command
1229        if words.len() >= 3 {
1230            return true;
1231        }
1232    }
1233
1234    // 2. Presence of English stop-words that are rare in CLI commands
1235    let stop_words = [
1236        "the", "a", "an", "on", "my", "your", "for", "with", "into", "onto",
1237    ];
1238    let stop_count = words.iter().filter(|w| stop_words.contains(w)).count();
1239    if stop_count >= 2 {
1240        return true;
1241    }
1242
1243    // 3. Lack of common CLI separators if many words exist
1244    if words.len() >= 5
1245        && !input.contains('-')
1246        && !input.contains('/')
1247        && !input.contains('\\')
1248        && !input.contains('.')
1249    {
1250        return true;
1251    }
1252
1253    false
1254}
1255
1256pub struct ConversationManager {
1257    /// Full conversation history in OpenAI format.
1258    pub history: Vec<ChatMessage>,
1259    pub engine: Arc<InferenceEngine>,
1260    pub tools: Vec<ToolDefinition>,
1261    pub mcp_manager: Arc<Mutex<crate::agent::mcp_manager::McpManager>>,
1262    pub professional: bool,
1263    pub brief: bool,
1264    pub snark: u8,
1265    pub chaos: u8,
1266    /// Model to use for simple read-only tasks (optional, user-supplied via --fast-model).
1267    pub fast_model: Option<String>,
1268    /// Model to use for complex write/build tasks (optional, user-supplied via --think-model).
1269    pub think_model: Option<String>,
1270    /// Files where whitespace auto-correction fired this session.
1271    pub correction_hints: Vec<String>,
1272    /// Running background summary of pruned older messages.
1273    pub running_summary: Option<String>,
1274    /// Live hardware telemetry handle.
1275    pub gpu_state: Arc<GpuState>,
1276    /// Local RAG memory — FTS5-indexed project source.
1277    pub vein: crate::memory::vein::Vein,
1278    /// Append-only session transcript logger.
1279    pub transcript: crate::agent::transcript::TranscriptLogger,
1280    /// Thread-safe cancellation signal for the current agent turn.
1281    pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
1282    /// Shared Git remote state (for persistent connectivity checks).
1283    pub git_state: Arc<crate::agent::git_monitor::GitState>,
1284    /// Reasoning think-mode override. None = let model decide. Some(true) = force /think.
1285    /// Some(false) = force /no_think (fast mode, 3-5x quicker for simple tasks).
1286    pub think_mode: Option<bool>,
1287    workflow_mode: WorkflowMode,
1288    /// Layer 6: Dynamic Task Context (extracted during compaction)
1289    pub session_memory: crate::agent::compaction::SessionMemory,
1290    pub swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1291    pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
1292    /// Personality description for the current Rusty soul — used in chat mode system prompt.
1293    pub soul_personality: String,
1294    pub lsp_manager: Arc<Mutex<crate::agent::lsp::manager::LspManager>>,
1295    /// Active reasoning summary extracted from the previous model turn (Gemma-4 Native).
1296    pub reasoning_history: Option<String>,
1297    /// Layer 8: Active Reference Pinning (Context Locked)
1298    pub pinned_files: Arc<Mutex<std::collections::HashMap<String, String>>>,
1299    /// Hard action-grounding state for proof-before-action checks.
1300    action_grounding: Arc<Mutex<ActionGroundingState>>,
1301    /// True only during `/code Implement the current plan.` style execution turns.
1302    plan_execution_active: Arc<std::sync::atomic::AtomicBool>,
1303    /// Nested depth of the current autonomous `/implement-plan` recursion chain.
1304    plan_execution_pass_depth: Arc<std::sync::atomic::AtomicUsize>,
1305    /// Typed per-turn recovery attempt tracking.
1306    recovery_context: RecoveryContext,
1307    /// L1 context block — hot files summary injected into the system prompt.
1308    /// Built once after vein init and updated as edits accumulate heat.
1309    pub l1_context: Option<String>,
1310    /// Condensed AST repository layout for the active project.
1311    pub repo_map: Option<String>,
1312    /// Number of real inference turns completed this session.
1313    pub turn_count: u32,
1314    /// Last user message sent to the model — persisted as checkpoint goal.
1315    pub last_goal: Option<String>,
1316    /// Most recent project directory created this session (Automatic Dive-In).
1317    pub latest_target_dir: Option<String>,
1318    /// One-shot plan handoff written into a newly created sovereign root before teleport.
1319    pending_teleport_handoff: Option<SovereignTeleportHandoff>,
1320}
1321
1322impl ConversationManager {
1323    fn vein_docs_only_mode(&self) -> bool {
1324        !crate::tools::file_ops::is_project_workspace()
1325    }
1326
1327    fn refresh_vein_index(&mut self) -> usize {
1328        let count = if self.vein_docs_only_mode() {
1329            tokio::task::block_in_place(|| {
1330                self.vein
1331                    .index_workspace_artifacts(&crate::tools::file_ops::hematite_dir())
1332            })
1333        } else {
1334            tokio::task::block_in_place(|| self.vein.index_project())
1335        };
1336        self.l1_context = self.vein.l1_context();
1337        count
1338    }
1339
1340    fn build_vein_inspection_report(&self, indexed_this_pass: usize) -> String {
1341        let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(8));
1342        let workspace_mode = if self.vein_docs_only_mode() {
1343            "docs-only (outside a project workspace)"
1344        } else {
1345            "project workspace"
1346        };
1347        let active_room = snapshot.active_room.as_deref().unwrap_or("none");
1348        let mut out = format!(
1349            "Vein Inspection\n\
1350             Workspace mode: {workspace_mode}\n\
1351             Indexed this pass: {indexed_this_pass}\n\
1352             Indexed source files: {}\n\
1353             Indexed docs: {}\n\
1354             Indexed session exchanges: {}\n\
1355             Embedded source/doc chunks: {}\n\
1356             Embeddings available: {}\n\
1357             Active room bias: {active_room}\n\
1358             L1 hot-files block: {}\n",
1359            snapshot.indexed_source_files,
1360            snapshot.indexed_docs,
1361            snapshot.indexed_session_exchanges,
1362            snapshot.embedded_source_doc_chunks,
1363            if snapshot.has_any_embeddings {
1364                "yes"
1365            } else {
1366                "no"
1367            },
1368            if snapshot.l1_ready {
1369                "ready"
1370            } else {
1371                "not built yet"
1372            },
1373        );
1374
1375        if snapshot.hot_files.is_empty() {
1376            out.push_str("Hot files: none yet.\n");
1377            return out;
1378        }
1379
1380        out.push_str("\nHot files by room:\n");
1381        let mut by_room: std::collections::BTreeMap<&str, Vec<&crate::memory::vein::VeinHotFile>> =
1382            std::collections::BTreeMap::new();
1383        for file in &snapshot.hot_files {
1384            by_room.entry(file.room.as_str()).or_default().push(file);
1385        }
1386        for (room, files) in by_room {
1387            out.push_str(&format!("[{}]\n", room));
1388            for file in files {
1389                out.push_str(&format!(
1390                    "- {} [{} edit{}]\n",
1391                    file.path,
1392                    file.heat,
1393                    if file.heat == 1 { "" } else { "s" }
1394                ));
1395            }
1396        }
1397
1398        out
1399    }
1400
1401    fn latest_user_prompt(&self) -> Option<&str> {
1402        self.history
1403            .iter()
1404            .rev()
1405            .find(|msg| msg.role == "user")
1406            .map(|msg| msg.content.as_str())
1407    }
1408
1409    async fn emit_direct_response(
1410        &mut self,
1411        tx: &mpsc::Sender<InferenceEvent>,
1412        raw_user_input: &str,
1413        effective_user_input: &str,
1414        response: &str,
1415    ) {
1416        self.history.push(ChatMessage::user(effective_user_input));
1417        self.history.push(ChatMessage::assistant_text(response));
1418        self.transcript.log_user(raw_user_input);
1419        self.transcript.log_agent(response);
1420        for chunk in chunk_text(response, 8) {
1421            if !chunk.is_empty() {
1422                let _ = tx.send(InferenceEvent::Token(chunk)).await;
1423            }
1424        }
1425        if let Some(path) = self.latest_target_dir.take() {
1426            self.persist_pending_teleport_handoff();
1427            let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
1428        }
1429        let _ = tx.send(InferenceEvent::Done).await;
1430        self.trim_history(80);
1431        self.refresh_session_memory();
1432        self.save_session();
1433    }
1434
1435    async fn emit_operator_checkpoint(
1436        &mut self,
1437        tx: &mpsc::Sender<InferenceEvent>,
1438        state: OperatorCheckpointState,
1439        summary: impl Into<String>,
1440    ) {
1441        let summary = summary.into();
1442        self.session_memory
1443            .record_checkpoint(state.label(), summary.clone());
1444        let _ = tx
1445            .send(InferenceEvent::OperatorCheckpoint { state, summary })
1446            .await;
1447    }
1448
1449    async fn emit_recovery_recipe_summary(
1450        &mut self,
1451        tx: &mpsc::Sender<InferenceEvent>,
1452        state: impl Into<String>,
1453        summary: impl Into<String>,
1454    ) {
1455        let state = state.into();
1456        let summary = summary.into();
1457        self.session_memory.record_recovery(state, summary.clone());
1458        let _ = tx.send(InferenceEvent::RecoveryRecipe { summary }).await;
1459    }
1460
1461    async fn emit_provider_live(&mut self, tx: &mpsc::Sender<InferenceEvent>) {
1462        let _ = tx
1463            .send(InferenceEvent::ProviderStatus {
1464                state: ProviderRuntimeState::Live,
1465                summary: String::new(),
1466            })
1467            .await;
1468        self.emit_operator_checkpoint(tx, OperatorCheckpointState::Idle, "")
1469            .await;
1470    }
1471
1472    async fn emit_prompt_pressure_for_messages(
1473        &self,
1474        tx: &mpsc::Sender<InferenceEvent>,
1475        messages: &[ChatMessage],
1476    ) {
1477        let context_length = self.engine.current_context_length();
1478        let (estimated_input_tokens, reserved_output_tokens, estimated_total_tokens, percent) =
1479            crate::agent::inference::estimate_prompt_pressure(
1480                messages,
1481                &self.tools,
1482                context_length,
1483            );
1484        let _ = tx
1485            .send(InferenceEvent::PromptPressure {
1486                estimated_input_tokens,
1487                reserved_output_tokens,
1488                estimated_total_tokens,
1489                context_length,
1490                percent,
1491            })
1492            .await;
1493    }
1494
1495    async fn emit_prompt_pressure_idle(&self, tx: &mpsc::Sender<InferenceEvent>) {
1496        let context_length = self.engine.current_context_length();
1497        let _ = tx
1498            .send(InferenceEvent::PromptPressure {
1499                estimated_input_tokens: 0,
1500                reserved_output_tokens: 0,
1501                estimated_total_tokens: 0,
1502                context_length,
1503                percent: 0,
1504            })
1505            .await;
1506    }
1507
1508    async fn emit_compaction_pressure(&self, tx: &mpsc::Sender<InferenceEvent>) {
1509        let context_length = self.engine.current_context_length();
1510        let vram_ratio = self.gpu_state.ratio();
1511        let config = CompactionConfig::adaptive(context_length, vram_ratio);
1512        let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
1513        let percent = if config.max_estimated_tokens == 0 {
1514            0
1515        } else {
1516            ((estimated_tokens.saturating_mul(100)) / config.max_estimated_tokens).min(100) as u8
1517        };
1518
1519        let _ = tx
1520            .send(InferenceEvent::CompactionPressure {
1521                estimated_tokens,
1522                threshold_tokens: config.max_estimated_tokens,
1523                percent,
1524            })
1525            .await;
1526    }
1527
1528    async fn refresh_runtime_profile_and_report(
1529        &mut self,
1530        tx: &mpsc::Sender<InferenceEvent>,
1531        reason: &str,
1532    ) -> Option<(String, usize, bool)> {
1533        let refreshed = self.engine.refresh_runtime_profile().await;
1534        if let Some((model_id, context_length, changed)) = refreshed.as_ref() {
1535            let _ = tx
1536                .send(InferenceEvent::RuntimeProfile {
1537                    model_id: model_id.clone(),
1538                    context_length: *context_length,
1539                })
1540                .await;
1541            self.transcript.log_system(&format!(
1542                "Runtime profile refresh ({}): model={} ctx={} changed={}",
1543                reason, model_id, context_length, changed
1544            ));
1545        }
1546        refreshed
1547    }
1548
1549    pub fn new(
1550        engine: Arc<InferenceEngine>,
1551        professional: bool,
1552        brief: bool,
1553        snark: u8,
1554        chaos: u8,
1555        soul_personality: String,
1556        fast_model: Option<String>,
1557        think_model: Option<String>,
1558        gpu_state: Arc<GpuState>,
1559        git_state: Arc<crate::agent::git_monitor::GitState>,
1560        swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1561        voice_manager: Arc<crate::ui::voice::VoiceManager>,
1562    ) -> Self {
1563        let (saved_summary, saved_memory) = load_session_data();
1564
1565        // Build the initial mcp_manager
1566        let mcp_manager = Arc::new(tokio::sync::Mutex::new(
1567            crate::agent::mcp_manager::McpManager::new(),
1568        ));
1569
1570        // Build the initial system prompt using the canonical InferenceEngine path.
1571        let dynamic_instructions =
1572            engine.build_system_prompt(snark, chaos, brief, professional, &[], None, &[]);
1573
1574        let history = vec![ChatMessage::system(&dynamic_instructions)];
1575
1576        let vein_path = crate::tools::file_ops::hematite_dir().join("vein.db");
1577        let vein_base_url = engine.base_url.clone();
1578        let vein = crate::memory::vein::Vein::new(&vein_path, vein_base_url.clone())
1579            .unwrap_or_else(|_| crate::memory::vein::Vein::new(":memory:", vein_base_url).unwrap());
1580
1581        Self {
1582            history,
1583            engine,
1584            tools: get_tools(),
1585            mcp_manager,
1586            professional,
1587            brief,
1588            snark,
1589            chaos,
1590            fast_model,
1591            think_model,
1592            correction_hints: Vec::new(),
1593            running_summary: saved_summary,
1594            gpu_state,
1595            vein,
1596            transcript: crate::agent::transcript::TranscriptLogger::new(),
1597            cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1598            git_state,
1599            think_mode: None,
1600            workflow_mode: WorkflowMode::Auto,
1601            session_memory: saved_memory,
1602            swarm_coordinator,
1603            voice_manager,
1604            soul_personality,
1605            lsp_manager: Arc::new(Mutex::new(crate::agent::lsp::manager::LspManager::new(
1606                crate::tools::file_ops::workspace_root(),
1607            ))),
1608            reasoning_history: None,
1609            pinned_files: Arc::new(Mutex::new(std::collections::HashMap::new())),
1610            action_grounding: Arc::new(Mutex::new(ActionGroundingState::default())),
1611            plan_execution_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1612            plan_execution_pass_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1613            recovery_context: RecoveryContext::default(),
1614            l1_context: None,
1615            repo_map: None,
1616            turn_count: 0,
1617            last_goal: None,
1618            latest_target_dir: None,
1619            pending_teleport_handoff: None,
1620        }
1621    }
1622
1623    async fn emit_done_events(&mut self, tx: &tokio::sync::mpsc::Sender<InferenceEvent>) {
1624        if let Some(path) = self.latest_target_dir.take() {
1625            self.persist_pending_teleport_handoff();
1626            let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
1627        }
1628        let _ = tx.send(InferenceEvent::Done).await;
1629    }
1630
1631    /// Index the project into The Vein. Call once after construction.
1632    /// Uses block_in_place so the tokio runtime thread isn't parked.
1633    pub fn initialize_vein(&mut self) -> usize {
1634        self.refresh_vein_index()
1635    }
1636
1637    /// Generate the AST Repo Map. Call once after construction or when resetting context.
1638    pub fn initialize_repo_map(&mut self) {
1639        if !self.vein_docs_only_mode() {
1640            let root = crate::tools::file_ops::workspace_root();
1641            let hot = self.vein.hot_files_weighted(10);
1642            let gen = crate::memory::repo_map::RepoMapGenerator::new(&root).with_hot_files(&hot);
1643            match tokio::task::block_in_place(|| gen.generate()) {
1644                Ok(map) => self.repo_map = Some(map),
1645                Err(e) => {
1646                    self.repo_map = Some(format!("Repo Map generation failed: {}", e));
1647                }
1648            }
1649        }
1650    }
1651
1652    /// Re-generate the repo map after a file edit so rankings stay fresh.
1653    /// Lightweight (~100-200ms) — called after successful mutations.
1654    fn refresh_repo_map(&mut self) {
1655        self.initialize_repo_map();
1656    }
1657
1658    fn save_session(&self) {
1659        let path = session_path();
1660        if let Some(parent) = path.parent() {
1661            let _ = std::fs::create_dir_all(parent);
1662        }
1663        let saved = SavedSession {
1664            running_summary: self.running_summary.clone(),
1665            session_memory: self.session_memory.clone(),
1666            last_goal: self.last_goal.clone(),
1667            turn_count: self.turn_count,
1668        };
1669        if let Ok(json) = serde_json::to_string(&saved) {
1670            let _ = std::fs::write(&path, json);
1671        }
1672    }
1673
1674    fn save_empty_session(&self) {
1675        let path = session_path();
1676        if let Some(parent) = path.parent() {
1677            let _ = std::fs::create_dir_all(parent);
1678        }
1679        let saved = SavedSession {
1680            running_summary: None,
1681            session_memory: crate::agent::compaction::SessionMemory::default(),
1682            last_goal: None,
1683            turn_count: 0,
1684        };
1685        if let Ok(json) = serde_json::to_string(&saved) {
1686            let _ = std::fs::write(&path, json);
1687        }
1688    }
1689
1690    fn refresh_session_memory(&mut self) {
1691        let current_plan = self.session_memory.current_plan.clone();
1692        let previous_memory = self.session_memory.clone();
1693        self.session_memory = compaction::extract_memory(&self.history);
1694        self.session_memory.current_plan = current_plan;
1695        self.session_memory
1696            .inherit_runtime_ledger_from(&previous_memory);
1697    }
1698
1699    fn build_chat_system_prompt(&self) -> String {
1700        let species = &self.engine.species;
1701        let personality = &self.soul_personality;
1702        format!(
1703            "You are {species}, a local AI companion running entirely on the user's GPU — no cloud, no subscriptions, no phoning home.\n\
1704             {personality}\n\n\
1705             This is CHAT mode — a clean conversational surface. Behave like a sharp friend who happens to know everything about code, not like an agent following a workflow.\n\n\
1706             Rules:\n\
1707             - Talk like a person. Skip the bullet-point breakdowns unless the topic genuinely needs structure.\n\
1708             - Answer directly. One paragraph is usually right.\n\
1709             - Don't call tools unless the user explicitly asks you to look at a file or run something.\n\
1710             - Don't narrate your reasoning or mention tool names unprompted.\n\
1711             - You can discuss code, debug ideas, explain concepts, help plan, or just talk.\n\
1712             - If the user clearly wants you to edit or build something, do it — but lead with conversation, not scaffolding.\n\
1713             - If the user wants the full coding harness, they can type `/agent`.\n",
1714        )
1715    }
1716
1717    fn append_session_handoff(&self, system_msg: &mut String) {
1718        let has_summary = self
1719            .running_summary
1720            .as_ref()
1721            .map(|s| !s.trim().is_empty())
1722            .unwrap_or(false);
1723        let has_memory = self.session_memory.has_signal();
1724
1725        if !has_summary && !has_memory {
1726            return;
1727        }
1728
1729        system_msg.push_str(
1730            "\n\n# LIGHTWEIGHT SESSION HANDOFF\n\
1731             This is compact carry-over from earlier work on this machine.\n\
1732             Use it only when it helps the current request.\n\
1733             Prefer current repository state, pinned files, and fresh tool results over stale session memory.\n",
1734        );
1735
1736        if has_memory {
1737            system_msg.push_str("\n## Active Task Memory\n");
1738            system_msg.push_str(&self.session_memory.to_prompt());
1739        }
1740
1741        if let Some(summary) = self.running_summary.as_deref() {
1742            if !summary.trim().is_empty() {
1743                system_msg.push_str("\n## Compacted Session Summary\n");
1744                system_msg.push_str(summary);
1745                system_msg.push('\n');
1746            }
1747        }
1748    }
1749
1750    fn set_workflow_mode(&mut self, mode: WorkflowMode) {
1751        self.workflow_mode = mode;
1752    }
1753
1754    fn current_plan_summary(&self) -> Option<String> {
1755        self.session_memory
1756            .current_plan
1757            .as_ref()
1758            .filter(|plan| plan.has_signal())
1759            .map(|plan| plan.summary_line())
1760    }
1761
1762    fn current_plan_allowed_paths(&self) -> Vec<String> {
1763        self.session_memory
1764            .current_plan
1765            .as_ref()
1766            .map(|plan| merge_plan_allowed_paths(&plan.target_files))
1767            .unwrap_or_default()
1768    }
1769
1770    fn current_plan_root_paths(&self) -> Vec<String> {
1771        use std::collections::BTreeSet;
1772
1773        let mut roots = BTreeSet::new();
1774        for path in self.current_plan_allowed_paths() {
1775            if let Some(parent) = std::path::Path::new(&path).parent() {
1776                roots.insert(parent.to_string_lossy().replace('\\', "/").to_lowercase());
1777            }
1778        }
1779        roots.into_iter().collect()
1780    }
1781
1782    fn persist_architect_handoff(
1783        &mut self,
1784        response: &str,
1785    ) -> Option<crate::tools::plan::PlanHandoff> {
1786        if self.workflow_mode != WorkflowMode::Architect {
1787            return None;
1788        }
1789        let Some(plan) = crate::tools::plan::parse_plan_handoff(response) else {
1790            return None;
1791        };
1792        let _ = crate::tools::plan::save_plan_handoff(&plan);
1793        self.session_memory.current_plan = Some(plan.clone());
1794        Some(plan)
1795    }
1796
1797    fn persist_pending_teleport_handoff(&mut self) {
1798        let Some(handoff) = self.pending_teleport_handoff.take() else {
1799            return;
1800        };
1801        let root = std::path::PathBuf::from(&handoff.root);
1802        let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &handoff.plan);
1803        let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
1804    }
1805
1806    async fn begin_grounded_turn(&self) -> u64 {
1807        let mut state = self.action_grounding.lock().await;
1808        state.turn_index += 1;
1809        state.turn_index
1810    }
1811
1812    async fn reset_action_grounding(&self) {
1813        let mut state = self.action_grounding.lock().await;
1814        *state = ActionGroundingState::default();
1815    }
1816
1817    /// Parse `@<path>` tokens from the raw user message and register any files that
1818    /// resolve to real paths as observed+inspected this turn. This lets the model
1819    /// call `edit_file` immediately on @-mentioned files without a read_file round-trip.
1820    async fn register_at_file_mentions(&self, input: &str) {
1821        if !input.contains('@') {
1822            return;
1823        }
1824        let cwd = match std::env::current_dir() {
1825            Ok(d) => d,
1826            Err(_) => return,
1827        };
1828        let mut state = self.action_grounding.lock().await;
1829        let turn = state.turn_index;
1830        for token in input.split_whitespace() {
1831            if !token.starts_with('@') {
1832                continue;
1833            }
1834            let raw = token
1835                .trim_start_matches('@')
1836                .trim_end_matches(|c: char| matches!(c, ',' | '.' | ':' | ';' | '!' | '?'));
1837            if raw.is_empty() {
1838                continue;
1839            }
1840            if cwd.join(raw).is_file() {
1841                let normalized = normalize_workspace_path(raw);
1842                state.observed_paths.insert(normalized.clone(), turn);
1843                state.inspected_paths.insert(normalized, turn);
1844            }
1845        }
1846    }
1847
1848    async fn record_read_observation(&self, path: &str) {
1849        let normalized = normalize_workspace_path(path);
1850        let mut state = self.action_grounding.lock().await;
1851        let turn = state.turn_index;
1852        // read_file returns full file content with line numbers — sufficient for
1853        // the model to know exact text before editing, so it satisfies the
1854        // line-inspection grounding check too.
1855        state.observed_paths.insert(normalized.clone(), turn);
1856        state.inspected_paths.insert(normalized, turn);
1857    }
1858
1859    async fn record_line_inspection(&self, path: &str) {
1860        let normalized = normalize_workspace_path(path);
1861        let mut state = self.action_grounding.lock().await;
1862        let turn = state.turn_index;
1863        state.observed_paths.insert(normalized.clone(), turn);
1864        state.inspected_paths.insert(normalized, turn);
1865    }
1866
1867    async fn record_verify_build_result(&self, ok: bool, output: &str) {
1868        let mut state = self.action_grounding.lock().await;
1869        let turn = state.turn_index;
1870        state.last_verify_build_turn = Some(turn);
1871        state.last_verify_build_ok = ok;
1872        if ok {
1873            state.code_changed_since_verify = false;
1874            state.last_failed_build_paths.clear();
1875        } else {
1876            state.last_failed_build_paths = parse_failing_paths_from_build_output(output);
1877        }
1878    }
1879
1880    fn record_session_verification(&mut self, ok: bool, summary: impl Into<String>) {
1881        self.session_memory.record_verification(ok, summary);
1882    }
1883
1884    async fn record_successful_mutation(&self, path: Option<&str>) {
1885        let mut state = self.action_grounding.lock().await;
1886        state.code_changed_since_verify = match path {
1887            Some(p) => is_code_like_path(p),
1888            None => true,
1889        };
1890    }
1891
1892    async fn validate_action_preconditions(&self, name: &str, args: &Value) -> Result<(), String> {
1893        if self
1894            .plan_execution_active
1895            .load(std::sync::atomic::Ordering::SeqCst)
1896        {
1897            if is_current_plan_irrelevant_tool(name) {
1898                let prompt = self.latest_user_prompt().unwrap_or("");
1899                let explicit_override = is_sovereign_path_request(prompt)
1900                    || prompt.contains(name)
1901                    || prompt.contains("/dev/null");
1902                if !explicit_override {
1903                    return Err(format!(
1904                        "Action blocked: `{}` is not part of current-plan execution. Stay on the saved target files, use built-in workspace file tools only, and either make a concrete edit or surface one specific blocker.",
1905                        name
1906                    ));
1907                }
1908            }
1909
1910            if is_plan_scoped_tool(name) {
1911                let allowed_paths = self.current_plan_allowed_paths();
1912                if !allowed_paths.is_empty() {
1913                    let allowed_roots = self.current_plan_root_paths();
1914                    let in_allowed = match name {
1915                        "auto_pin_context" => args
1916                            .get("paths")
1917                            .and_then(|v| v.as_array())
1918                            .map(|paths| {
1919                                !paths.is_empty()
1920                                    && paths.iter().all(|v| {
1921                                        v.as_str()
1922                                            .map(normalize_workspace_path)
1923                                            .map(|p| allowed_paths.contains(&p))
1924                                            .unwrap_or(false)
1925                                    })
1926                            })
1927                            .unwrap_or(false),
1928                        "grep_files" | "list_files" => {
1929                            let raw_val = args.get("path").and_then(|v| v.as_str());
1930                            let path_to_check = if let Some(p) = raw_val {
1931                                let trimmed = p.trim();
1932                                if trimmed.is_empty() || trimmed == "." || trimmed == "./" {
1933                                    ""
1934                                } else {
1935                                    trimmed
1936                                }
1937                            } else {
1938                                ""
1939                            };
1940                            // Always allow listing the workspace root — the model needs
1941                            // directory recon to locate plan targets.
1942                            if path_to_check.is_empty() {
1943                                true
1944                            } else {
1945                                let p = normalize_workspace_path(path_to_check);
1946                                // Allow if the path IS an allowed file, OR is a parent dir
1947                                // of any allowed file (the model needs to ls the parent).
1948                                allowed_paths.contains(&p)
1949                                    || allowed_roots.iter().any(|root| root == &p)
1950                                    || allowed_paths.iter().any(|ap| {
1951                                        ap.starts_with(&format!("{}/", p))
1952                                            || ap.starts_with(&format!("{}\\", p))
1953                                    })
1954                            }
1955                        }
1956                        _ => {
1957                            let target = action_target_path(name, args);
1958                            let in_allowed = target
1959                                .as_ref()
1960                                .map(|p| allowed_paths.contains(p))
1961                                .unwrap_or(false);
1962                            let raw_path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
1963                            in_allowed || is_sovereign_path_request(raw_path)
1964                        }
1965                    };
1966
1967                    if !in_allowed {
1968                        let allowed = allowed_paths
1969                            .iter()
1970                            .map(|p| format!("`{}`", p))
1971                            .collect::<Vec<_>>()
1972                            .join(", ");
1973                        return Err(format!(
1974                            "Action blocked: current-plan execution is locked to the saved target files. Use a path-scoped built-in tool on one of these files only: {}.",
1975                            allowed
1976                        ));
1977                    }
1978                }
1979            }
1980
1981            if matches!(name, "edit_file" | "multi_search_replace" | "patch_hunk") {
1982                if let Some(target) = action_target_path(name, args) {
1983                    let state = self.action_grounding.lock().await;
1984                    let recently_inspected = state
1985                        .inspected_paths
1986                        .get(&target)
1987                        .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1988                        .unwrap_or(false);
1989                    drop(state);
1990                    if !recently_inspected {
1991                        return Err(format!(
1992                            "Action blocked: `{}` on '{}' requires an exact local line window first during current-plan execution. Use `inspect_lines` on that file around the intended edit region, then retry the mutation.",
1993                            name, target
1994                        ));
1995                    }
1996                }
1997            }
1998        }
1999
2000        if self.workflow_mode.is_read_only() && name == "auto_pin_context" {
2001            return Err(
2002                "Action blocked: `auto_pin_context` is disabled in read-only workflows. Use the grounded file evidence you already have, or narrow with `inspect_lines` instead of pinning more files into active context."
2003                    .to_string(),
2004            );
2005        }
2006
2007        if self.workflow_mode.is_read_only() && is_destructive_tool(name) {
2008            if name == "shell" {
2009                let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2010                let risk = crate::tools::guard::classify_bash_risk(command);
2011                if !matches!(risk, crate::tools::RiskLevel::Safe) {
2012                    return Err(format!(
2013                        "Action blocked: workflow mode `{}` is read-only for risky or mutating operations. Switch to `/code` or `/auto` before making changes.",
2014                        self.workflow_mode.label()
2015                    ));
2016                }
2017            } else {
2018                return Err(format!(
2019                    "Action blocked: workflow mode `{}` is read-only. Use `/code` to implement changes or `/auto` to leave mode selection to Hematite.",
2020                    self.workflow_mode.label()
2021                ));
2022            }
2023        }
2024
2025        let normalized_target = action_target_path(name, args);
2026        if let Some(target) = normalized_target.as_deref() {
2027            if matches!(
2028                name,
2029                "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
2030            ) {
2031                if let Some(prompt) = self.latest_user_prompt() {
2032                    if docs_edit_without_explicit_request(prompt, target) {
2033                        return Err(format!(
2034                            "Action blocked: '{}' is a docs file but the current request did not explicitly ask for documentation changes. Finish the code task first. If docs need updating, the user will ask.",
2035                            target
2036                        ));
2037                    }
2038                }
2039            }
2040            let path_exists = std::path::Path::new(target).exists();
2041            if path_exists {
2042                let state = self.action_grounding.lock().await;
2043                let pinned = self.pinned_files.lock().await;
2044                let pinned_match = pinned.keys().any(|p| normalize_workspace_path(p) == target);
2045                drop(pinned);
2046
2047                // edit_file and multi_search_replace match text exactly, so they need a
2048                // tighter evidence bar than a plain read. Require inspect_lines on the
2049                // target within the last 3 turns. A read_file in the *same* turn is also
2050                // accepted (the model just loaded the file and is making an immediate edit).
2051                let needs_exact_window = matches!(name, "edit_file" | "multi_search_replace");
2052                let recently_inspected = state
2053                    .inspected_paths
2054                    .get(target)
2055                    .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2056                    .unwrap_or(false);
2057                let same_turn_read = state
2058                    .observed_paths
2059                    .get(target)
2060                    .map(|turn| state.turn_index.saturating_sub(*turn) == 0)
2061                    .unwrap_or(false);
2062                let recent_observed = state
2063                    .observed_paths
2064                    .get(target)
2065                    .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2066                    .unwrap_or(false);
2067
2068                if matches!(
2069                    name,
2070                    "read_file" | "inspect_lines" | "list_files" | "grep_files"
2071                ) {
2072                    // These are the grounding tools themselves; they should be allowed to
2073                    // establish evidence on an already-allowed target path.
2074                } else if name == "write_file" && matches!(self.workflow_mode, WorkflowMode::Code) {
2075                    let size = std::fs::metadata(target).map(|m| m.len()).unwrap_or(0);
2076                    if size > 2000 {
2077                        // SURGICAL MANDATE: In CODE mode, for files larger than 2KB, we block full-file rewrites.
2078                        return Err(format!(
2079                            "SURGICAL MANDATE: '{}' already exists and is significant ({} bytes). In implementation mode, you must use `edit_file` or `patch_hunk` for targeted changes instead of rewriting the entire file with `write_file`. This maintains project integrity and prevents context burn. HINT: Use `read_file` to capture the current state, then use `edit_file` with the exact text you want to replace in `target_content`.",
2080                            target, size
2081                        ));
2082                    }
2083                } else if needs_exact_window {
2084                    if !recently_inspected && !same_turn_read && !pinned_match {
2085                        return Err(format!(
2086                            "Action blocked: `{}` on '{}' requires a line-level inspection first. \
2087                             Use `inspect_lines` on the target region to get the exact current text \
2088                             (whitespace and indentation included), then retry the edit.",
2089                            name, target
2090                        ));
2091                    }
2092                } else if !recent_observed && !pinned_match {
2093                    return Err(format!(
2094                        "Action blocked: `{}` on '{}' requires recent file evidence. Use `read_file` or `inspect_lines` on that path first, or pin the file into active context.",
2095                        name, target
2096                    ));
2097                }
2098            }
2099        }
2100
2101        if is_mcp_mutating_tool(name) {
2102            return Err(format!(
2103                "Action blocked: `{}` is an external MCP mutation tool. For workspace file edits, prefer Hematite's built-in edit path (`read_file`/`inspect_lines` plus `patch_hunk`, `edit_file`, or `multi_search_replace`) unless the user explicitly requires MCP for that action.",
2104                name
2105            ));
2106        }
2107
2108        if is_mcp_workspace_read_tool(name) {
2109            return Err(format!(
2110                "Action blocked: `{}` is an external MCP filesystem read tool. For local workspace inspection, prefer Hematite's built-in read path (`read_file`, `inspect_lines`, `list_files`, or `grep_files`) unless the user explicitly requires MCP for that action.",
2111                name
2112            ));
2113        }
2114
2115        // Phase gate: if the build is broken, constrain edits to files that cargo flagged.
2116        // This prevents the model from wandering to unrelated files after a failed verify.
2117        if matches!(
2118            name,
2119            "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
2120        ) {
2121            if let Some(target) = normalized_target.as_deref() {
2122                let state = self.action_grounding.lock().await;
2123                if state.code_changed_since_verify
2124                    && !state.last_verify_build_ok
2125                    && !state.last_failed_build_paths.is_empty()
2126                    && !state.last_failed_build_paths.iter().any(|p| p == target)
2127                {
2128                    let files = state
2129                        .last_failed_build_paths
2130                        .iter()
2131                        .map(|p| format!("`{}`", p))
2132                        .collect::<Vec<_>>()
2133                        .join(", ");
2134                    return Err(format!(
2135                        "Action blocked: the build is broken. Fix the errors in {} before editing other files. Re-run workspace verification to confirm the fix, then continue.",
2136                        files
2137                    ));
2138                }
2139            }
2140        }
2141
2142        if name == "git_commit" || name == "git_push" {
2143            let state = self.action_grounding.lock().await;
2144            if state.code_changed_since_verify && !state.last_verify_build_ok {
2145                return Err(format!(
2146                    "Action blocked: `{}` requires a successful verification pass after the latest code edits. Run verification first so Hematite has proof that the workspace is clean.",
2147                    name
2148                ));
2149            }
2150        }
2151
2152        if name == "shell" {
2153            let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2154            if shell_looks_like_structured_host_inspection(command) {
2155                // Auto-redirect: silently call inspect_host with the right topic instead of
2156                // returning a block error that the model may fail to recover from.
2157                // Derive topic ONLY from the shell command itself. We do not fall back to the user prompt
2158                // here to avoid trapping secondary shell commands in a redirection loop based on the primary intent.
2159                let topic = match preferred_host_inspection_topic(command) {
2160                    Some(t) => t.to_string(),
2161                    None => return Ok(()), // Not a clear host inspection command, allow it to pass through.
2162                };
2163
2164                {
2165                    let mut state = self.action_grounding.lock().await;
2166                    let current_turn = state.turn_index;
2167                    if let Some(turn) = state.redirected_host_inspection_topics.get(&topic) {
2168                        if *turn == current_turn {
2169                            return Err(format!(
2170                                "[auto-redirected shell→inspect_host(topic=\"{topic}\")] Notice: The diagnostic data for topic `{topic}` was already provided in this turn. Using the previous result to avoid redundant tool calls."
2171                            ));
2172                        }
2173                    }
2174                    state
2175                        .redirected_host_inspection_topics
2176                        .insert(topic.clone(), current_turn);
2177                }
2178
2179                let path_val = self
2180                    .latest_user_prompt()
2181                    .and_then(|p| {
2182                        // Very basic heuristic for path extraction: look for strings with dots/slashes
2183                        p.split_whitespace()
2184                            .find(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
2185                            .map(|s| {
2186                                s.trim_matches(|c: char| {
2187                                    !c.is_alphanumeric() && c != '.' && c != '/' && c != '\\'
2188                                })
2189                            })
2190                    })
2191                    .unwrap_or("");
2192
2193                let mut redirect_args = if !path_val.is_empty() {
2194                    serde_json::json!({ "topic": topic, "path": path_val })
2195                } else {
2196                    serde_json::json!({ "topic": topic })
2197                };
2198
2199                // Surgical Argument Extraction for redirected shell payloads.
2200                if topic == "dns_lookup" {
2201                    if let Some(identity) = extract_dns_lookup_target_from_shell(command) {
2202                        redirect_args
2203                            .as_object_mut()
2204                            .unwrap()
2205                            .insert("name".to_string(), serde_json::Value::String(identity));
2206                    }
2207                    if let Some(record_type) = extract_dns_record_type_from_shell(command) {
2208                        redirect_args.as_object_mut().unwrap().insert(
2209                            "type".to_string(),
2210                            serde_json::Value::String(record_type.to_string()),
2211                        );
2212                    }
2213                } else if topic == "ad_user" {
2214                    let cmd_lower = command.to_lowercase();
2215                    let mut identity = String::new();
2216
2217                    // 1. Explicit Identity check
2218                    if let Some(idx) = cmd_lower.find("-identity") {
2219                        let after_id = &command[idx + 9..].trim();
2220                        identity = if after_id.starts_with('\'') || after_id.starts_with('"') {
2221                            let quote = after_id.chars().next().unwrap();
2222                            after_id.split(quote).nth(1).unwrap_or("").to_string()
2223                        } else {
2224                            after_id.split_whitespace().next().unwrap_or("").to_string()
2225                        };
2226                    }
2227
2228                    // 2. Wide-Net Fallback: Find the first non-cmdlet, non-parameter string
2229                    if identity.is_empty() {
2230                        let parts: Vec<&str> = command.split_whitespace().collect();
2231                        for (i, part) in parts.iter().enumerate() {
2232                            if i == 0 || part.starts_with('-') {
2233                                continue;
2234                            }
2235                            // Skip common cmdlets if they are in the parts list
2236                            let p_low = part.to_lowercase();
2237                            if p_low.contains("get-ad")
2238                                || p_low.contains("powershell")
2239                                || p_low == "-command"
2240                            {
2241                                continue;
2242                            }
2243
2244                            identity = part
2245                                .trim_matches(|c: char| c == '\'' || c == '"')
2246                                .to_string();
2247                            if !identity.is_empty() {
2248                                break;
2249                            }
2250                        }
2251                    }
2252
2253                    if !identity.is_empty() {
2254                        redirect_args.as_object_mut().unwrap().insert(
2255                            "name_filter".to_string(),
2256                            serde_json::Value::String(identity),
2257                        );
2258                    }
2259                }
2260
2261                let result = crate::tools::host_inspect::inspect_host(&redirect_args).await;
2262                return match result {
2263                    Ok(output) => Err(format!(
2264                        "[auto-redirected shell→inspect_host(topic=\"{topic}\")]\n\n{output}\n\n[Note: Shell is blocked for host inspection. The diagnostic data above fulfills your request. Use inspect_host directly for further diagnostics.]"
2265                    )),
2266                    Err(e) => Err(format!(
2267                        "Redirection to native tool `{topic}` failed: {e}\n\nAction blocked: use `inspect_host(topic: \"{topic}\")` instead of raw `shell` for host-inspection questions. Available topics: updates, security, pending_reboot, disk_health, battery, recent_crashes, scheduled_tasks, dev_conflicts, health_report, storage, hardware, resource_load, overclocker, processes, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, hyperv, event_query, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, network_profile, services, ports, env_doctor, fix_plan, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, docker, docker_filesystems, wsl, wsl_filesystems, ssh, env, hosts_file, installed_software, git_config, databases, disk_benchmark, directory, permissions, login_history, registry_audit, share_access.",
2268                    )),
2269                };
2270            }
2271            let reason = args
2272                .get("reason")
2273                .and_then(|v| v.as_str())
2274                .unwrap_or("")
2275                .trim();
2276            let risk = crate::tools::guard::classify_bash_risk(command);
2277            if !matches!(risk, crate::tools::RiskLevel::Safe) && reason.is_empty() {
2278                return Err(
2279                    "Action blocked: risky `shell` calls require a concrete `reason` argument that explains what is being verified or changed."
2280                        .to_string(),
2281                );
2282            }
2283        }
2284
2285        Ok(())
2286    }
2287
2288    fn build_action_receipt(
2289        &self,
2290        name: &str,
2291        args: &Value,
2292        output: &str,
2293        is_error: bool,
2294    ) -> Option<ChatMessage> {
2295        if is_error || !is_destructive_tool(name) {
2296            return None;
2297        }
2298
2299        let mut receipt = String::from("[ACTION RECEIPT]\n");
2300        receipt.push_str(&format!("- tool: {}\n", name));
2301        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
2302            receipt.push_str(&format!("- target: {}\n", path));
2303        }
2304        if name == "shell" {
2305            if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
2306                receipt.push_str(&format!("- command: {}\n", command));
2307            }
2308            if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
2309                if !reason.trim().is_empty() {
2310                    receipt.push_str(&format!("- reason: {}\n", reason.trim()));
2311                }
2312            }
2313        }
2314        let first_line = output.lines().next().unwrap_or(output).trim();
2315        receipt.push_str(&format!("- outcome: {}\n", first_line));
2316        Some(ChatMessage::system(&receipt))
2317    }
2318
2319    fn replace_mcp_tool_definitions(&mut self, mcp_tools: &[crate::agent::mcp::McpTool]) {
2320        self.tools
2321            .retain(|tool| !tool.function.name.starts_with("mcp__"));
2322        self.tools
2323            .extend(mcp_tools.iter().map(|tool| ToolDefinition {
2324                tool_type: "function".into(),
2325                function: ToolFunction {
2326                    name: tool.name.clone(),
2327                    description: tool.description.clone().unwrap_or_default(),
2328                    parameters: tool.input_schema.clone(),
2329                },
2330                metadata: crate::agent::inference::tool_metadata_for_name(&tool.name),
2331            }));
2332    }
2333
2334    async fn emit_mcp_runtime_status(&self, tx: &mpsc::Sender<InferenceEvent>) {
2335        let summary = {
2336            let mcp = self.mcp_manager.lock().await;
2337            mcp.runtime_report()
2338        };
2339        let _ = tx
2340            .send(InferenceEvent::McpStatus {
2341                state: summary.state,
2342                summary: summary.summary,
2343            })
2344            .await;
2345    }
2346
2347    async fn refresh_mcp_tools(
2348        &mut self,
2349        tx: &mpsc::Sender<InferenceEvent>,
2350    ) -> Result<Vec<crate::agent::mcp::McpTool>, Box<dyn std::error::Error + Send + Sync>> {
2351        let mcp_tools = {
2352            let mut mcp = self.mcp_manager.lock().await;
2353            match mcp.initialize_all().await {
2354                Ok(()) => mcp.discover_tools().await,
2355                Err(e) => {
2356                    drop(mcp);
2357                    self.replace_mcp_tool_definitions(&[]);
2358                    self.emit_mcp_runtime_status(tx).await;
2359                    return Err(e.into());
2360                }
2361            }
2362        };
2363
2364        self.replace_mcp_tool_definitions(&mcp_tools);
2365        self.emit_mcp_runtime_status(tx).await;
2366        Ok(mcp_tools)
2367    }
2368
2369    /// Spawns and initializes all configured MCP servers, discovering their tools.
2370    pub async fn initialize_mcp(
2371        &mut self,
2372        tx: &mpsc::Sender<InferenceEvent>,
2373    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2374        let _ = self.refresh_mcp_tools(tx).await?;
2375        Ok(())
2376    }
2377
2378    /// Run one user turn through the full agentic loop.
2379    ///
2380    /// Adds the user message, calls the model, executes any tools, and loops
2381    /// until the model produces a final text reply.  All progress is streamed
2382    /// as `InferenceEvent` values via `tx`.
2383    pub async fn run_turn(
2384        &mut self,
2385        user_turn: &UserTurn,
2386        tx: mpsc::Sender<InferenceEvent>,
2387        yolo: bool,
2388    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2389        let user_input = user_turn.text.as_str();
2390        // ── Fast-path reset commands: handled locally, no network I/O needed ──
2391        if user_input.trim() == "/new" {
2392            self.history.clear();
2393            self.reasoning_history = None;
2394            self.session_memory.clear();
2395            self.running_summary = None;
2396            self.correction_hints.clear();
2397            self.pinned_files.lock().await.clear();
2398            self.reset_action_grounding().await;
2399            reset_task_files();
2400            let _ = std::fs::remove_file(session_path());
2401            self.save_empty_session();
2402            self.emit_compaction_pressure(&tx).await;
2403            self.emit_prompt_pressure_idle(&tx).await;
2404            for chunk in chunk_text(
2405                "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
2406                8,
2407            ) {
2408                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2409            }
2410            let _ = tx.send(InferenceEvent::Done).await;
2411            return Ok(());
2412        }
2413
2414        if user_input.trim() == "/forget" {
2415            self.history.clear();
2416            self.reasoning_history = None;
2417            self.session_memory.clear();
2418            self.running_summary = None;
2419            self.correction_hints.clear();
2420            self.pinned_files.lock().await.clear();
2421            self.reset_action_grounding().await;
2422            reset_task_files();
2423            purge_persistent_memory();
2424            tokio::task::block_in_place(|| self.vein.reset());
2425            let _ = std::fs::remove_file(session_path());
2426            self.save_empty_session();
2427            self.emit_compaction_pressure(&tx).await;
2428            self.emit_prompt_pressure_idle(&tx).await;
2429            for chunk in chunk_text(
2430                "Hard forget complete. Chat history, saved memory, task files, and the Vein index were purged.",
2431                8,
2432            ) {
2433                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2434            }
2435            let _ = tx.send(InferenceEvent::Done).await;
2436            return Ok(());
2437        }
2438
2439        if user_input.trim() == "/vein-inspect" {
2440            let indexed = self.refresh_vein_index();
2441            let report = self.build_vein_inspection_report(indexed);
2442            let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
2443            let _ = tx
2444                .send(InferenceEvent::VeinStatus {
2445                    file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
2446                    embedded_count: snapshot.embedded_source_doc_chunks,
2447                    docs_only: self.vein_docs_only_mode(),
2448                })
2449                .await;
2450            for chunk in chunk_text(&report, 8) {
2451                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2452            }
2453            let _ = tx.send(InferenceEvent::Done).await;
2454            return Ok(());
2455        }
2456
2457        if user_input.trim() == "/workspace-profile" {
2458            let root = crate::tools::file_ops::workspace_root();
2459            let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
2460            let report = crate::agent::workspace_profile::profile_report(&root);
2461            for chunk in chunk_text(&report, 8) {
2462                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2463            }
2464            let _ = tx.send(InferenceEvent::Done).await;
2465            return Ok(());
2466        }
2467
2468        if user_input.trim() == "/rules" {
2469            let rules_path = crate::tools::file_ops::hematite_dir().join("rules.md");
2470            let report = if rules_path.exists() {
2471                match std::fs::read_to_string(&rules_path) {
2472                    Ok(content) => format!(
2473                        "## Behavioral Rules (.hematite/rules.md)\n\n{}\n\n---\nTo update: ask Hematite to edit your rules, or open `.hematite/rules.md` directly. Changes take effect on the next turn.",
2474                        content.trim()
2475                    ),
2476                    Err(e) => format!("Error reading .hematite/rules.md: {e}"),
2477                }
2478            } else {
2479                format!(
2480                    "No behavioral rules file found at `.hematite/rules.md`.\n\nCreate it to add custom behavioral guidelines — they are injected into the system prompt on every turn and apply to any model you load.\n\nExample: ask Hematite to \"create a rules.md with simplicity-first and surgical-edit guidelines\" and it will write the file for you.\n\nExpected path: {}",
2481                    rules_path.display()
2482                )
2483            };
2484            for chunk in chunk_text(&report, 8) {
2485                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2486            }
2487            let _ = tx.send(InferenceEvent::Done).await;
2488            return Ok(());
2489        }
2490
2491        if user_input.trim() == "/vein-reset" {
2492            tokio::task::block_in_place(|| self.vein.reset());
2493            let _ = tx
2494                .send(InferenceEvent::VeinStatus {
2495                    file_count: 0,
2496                    embedded_count: 0,
2497                    docs_only: self.vein_docs_only_mode(),
2498                })
2499                .await;
2500            for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
2501                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2502            }
2503            let _ = tx.send(InferenceEvent::Done).await;
2504            return Ok(());
2505        }
2506
2507        // Reload config every turn (edits apply immediately, no restart needed).
2508        let config = crate::agent::config::load_config();
2509        self.recovery_context.clear();
2510        let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
2511        if !manual_runtime_refresh {
2512            if let Some((model_id, context_length, changed)) = self
2513                .refresh_runtime_profile_and_report(&tx, "turn_start")
2514                .await
2515            {
2516                if changed {
2517                    let _ = tx
2518                        .send(InferenceEvent::Thought(format!(
2519                            "Runtime refresh: using model `{}` with CTX {} for this turn.",
2520                            model_id, context_length
2521                        )))
2522                        .await;
2523                }
2524            }
2525        }
2526        self.emit_compaction_pressure(&tx).await;
2527        let current_model = self.engine.current_model();
2528        self.engine.set_gemma_native_formatting(
2529            crate::agent::config::effective_gemma_native_formatting(&config, &current_model),
2530        );
2531        let _turn_id = self.begin_grounded_turn().await;
2532        let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
2533        let mcp_tools = match self.refresh_mcp_tools(&tx).await {
2534            Ok(tools) => tools,
2535            Err(e) => {
2536                let _ = tx
2537                    .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
2538                    .await;
2539                Vec::new()
2540            }
2541        };
2542
2543        // Apply config model overrides (config takes precedence over CLI flags).
2544        let effective_fast = config
2545            .fast_model
2546            .clone()
2547            .or_else(|| self.fast_model.clone());
2548        let effective_think = config
2549            .think_model
2550            .clone()
2551            .or_else(|| self.think_model.clone());
2552
2553        // ── /lsp: start language servers manually if needed ──────────────────
2554        if user_input.trim() == "/lsp" {
2555            let mut lsp = self.lsp_manager.lock().await;
2556            match lsp.start_servers().await {
2557                Ok(_) => {
2558                    let _ = tx
2559                        .send(InferenceEvent::MutedToken(
2560                            "LSP: Servers Initialized OK.".to_string(),
2561                        ))
2562                        .await;
2563                }
2564                Err(e) => {
2565                    let _ = tx
2566                        .send(InferenceEvent::Error(format!(
2567                            "LSP: Failed to start servers - {}",
2568                            e
2569                        )))
2570                        .await;
2571                }
2572            }
2573            let _ = tx.send(InferenceEvent::Done).await;
2574            return Ok(());
2575        }
2576
2577        if user_input.trim() == "/runtime-refresh" {
2578            match self
2579                .refresh_runtime_profile_and_report(&tx, "manual_command")
2580                .await
2581            {
2582                Some((model_id, context_length, changed)) => {
2583                    let msg = if changed {
2584                        format!(
2585                            "Runtime profile refreshed. Model: {} | CTX: {}",
2586                            model_id, context_length
2587                        )
2588                    } else {
2589                        format!(
2590                            "Runtime profile unchanged. Model: {} | CTX: {}",
2591                            model_id, context_length
2592                        )
2593                    };
2594                    for chunk in chunk_text(&msg, 8) {
2595                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
2596                    }
2597                }
2598                None => {
2599                    let _ = tx
2600                        .send(InferenceEvent::Error(
2601                            "Runtime refresh failed: LM Studio profile could not be read."
2602                                .to_string(),
2603                        ))
2604                        .await;
2605                }
2606            }
2607            let _ = tx.send(InferenceEvent::Done).await;
2608            return Ok(());
2609        }
2610
2611        if user_input.trim() == "/ask" {
2612            self.set_workflow_mode(WorkflowMode::Ask);
2613            for chunk in chunk_text(
2614                "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
2615                8,
2616            ) {
2617                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2618            }
2619            let _ = tx.send(InferenceEvent::Done).await;
2620            return Ok(());
2621        }
2622
2623        if user_input.trim() == "/code" {
2624            self.set_workflow_mode(WorkflowMode::Code);
2625            let mut message =
2626                "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
2627            if let Some(plan) = self.current_plan_summary() {
2628                message.push_str(&format!(" Current plan: {plan}."));
2629            }
2630            for chunk in chunk_text(&message, 8) {
2631                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2632            }
2633            let _ = tx.send(InferenceEvent::Done).await;
2634            return Ok(());
2635        }
2636
2637        if user_input.trim() == "/architect" {
2638            self.set_workflow_mode(WorkflowMode::Architect);
2639            let mut message =
2640                "Workflow mode: ARCHITECT. Plan, inspect, and shape the approach first. Do not mutate code unless the user explicitly asks to implement. When the handoff is ready, use `/implement-plan` or switch to `/code` to execute it.".to_string();
2641            if let Some(plan) = self.current_plan_summary() {
2642                message.push_str(&format!(" Existing plan: {plan}."));
2643            }
2644            for chunk in chunk_text(&message, 8) {
2645                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2646            }
2647            let _ = tx.send(InferenceEvent::Done).await;
2648            return Ok(());
2649        }
2650
2651        if user_input.trim() == "/read-only" {
2652            self.set_workflow_mode(WorkflowMode::ReadOnly);
2653            for chunk in chunk_text(
2654                "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
2655                8,
2656            ) {
2657                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2658            }
2659            let _ = tx.send(InferenceEvent::Done).await;
2660            return Ok(());
2661        }
2662
2663        if user_input.trim() == "/auto" {
2664            self.set_workflow_mode(WorkflowMode::Auto);
2665            for chunk in chunk_text(
2666                "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
2667                8,
2668            ) {
2669                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2670            }
2671            let _ = tx.send(InferenceEvent::Done).await;
2672            return Ok(());
2673        }
2674
2675        if user_input.trim() == "/chat" {
2676            self.set_workflow_mode(WorkflowMode::Chat);
2677            let _ = tx.send(InferenceEvent::Done).await;
2678            return Ok(());
2679        }
2680
2681        if user_input.trim() == "/teach" {
2682            self.set_workflow_mode(WorkflowMode::Teach);
2683            for chunk in chunk_text(
2684                "Workflow mode: TEACH. I will inspect your actual machine state first, then walk you through any admin, config, or write task as a grounded, numbered tutorial. I will not execute write operations — I will show you exactly how to do each step yourself.",
2685                8,
2686            ) {
2687                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2688            }
2689            let _ = tx.send(InferenceEvent::Done).await;
2690            return Ok(());
2691        }
2692
2693        if user_input.trim() == "/reroll" {
2694            let soul = crate::ui::hatch::generate_soul_random();
2695            self.snark = soul.snark;
2696            self.chaos = soul.chaos;
2697            self.soul_personality = soul.personality.clone();
2698            // Update the engine's species name so build_chat_system_prompt uses it
2699            // SAFETY: engine is Arc but species is a plain String field we own logically.
2700            // We use Arc::get_mut which only succeeds if this is the only strong ref.
2701            // If it fails (swarm workers hold refs), we fall back to a best-effort clone approach.
2702            let species = soul.species.clone();
2703            if let Some(eng) = Arc::get_mut(&mut self.engine) {
2704                eng.species = species.clone();
2705            }
2706            let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
2707            let _ = tx
2708                .send(InferenceEvent::SoulReroll {
2709                    species: soul.species.clone(),
2710                    rarity: soul.rarity.label().to_string(),
2711                    shiny: soul.shiny,
2712                    personality: soul.personality.clone(),
2713                })
2714                .await;
2715            for chunk in chunk_text(
2716                &format!(
2717                    "A new companion awakens!\n[{}{}] {} — \"{}\"",
2718                    soul.rarity.label(),
2719                    shiny_tag,
2720                    soul.species,
2721                    soul.personality
2722                ),
2723                8,
2724            ) {
2725                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2726            }
2727            let _ = tx.send(InferenceEvent::Done).await;
2728            return Ok(());
2729        }
2730
2731        if user_input.trim() == "/agent" {
2732            self.set_workflow_mode(WorkflowMode::Auto);
2733            let _ = tx.send(InferenceEvent::Done).await;
2734            return Ok(());
2735        }
2736
2737        let implement_plan_alias = user_input.trim() == "/implement-plan";
2738        if implement_plan_alias
2739            && !self
2740                .session_memory
2741                .current_plan
2742                .as_ref()
2743                .map(|plan| plan.has_signal())
2744                .unwrap_or(false)
2745        {
2746            for chunk in chunk_text(
2747                "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
2748                8,
2749            ) {
2750                let _ = tx.send(InferenceEvent::Token(chunk)).await;
2751            }
2752            let _ = tx.send(InferenceEvent::Done).await;
2753            return Ok(());
2754        }
2755
2756        let mut effective_user_input = if implement_plan_alias {
2757            self.set_workflow_mode(WorkflowMode::Code);
2758            implement_current_plan_prompt().to_string()
2759        } else {
2760            user_input.trim().to_string()
2761        };
2762        if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
2763            self.set_workflow_mode(mode);
2764            effective_user_input = rest.to_string();
2765        }
2766        let transcript_user_input = if implement_plan_alias {
2767            transcript_user_turn_text(user_turn, "/implement-plan")
2768        } else {
2769            transcript_user_turn_text(user_turn, &effective_user_input)
2770        };
2771        effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
2772        // Register @file mentions in action_grounding so the model can edit them
2773        // immediately without a separate read_file round-trip.
2774        self.register_at_file_mentions(user_input).await;
2775        let implement_current_plan = self.workflow_mode == WorkflowMode::Code
2776            && is_current_plan_execution_request(&effective_user_input)
2777            && self
2778                .session_memory
2779                .current_plan
2780                .as_ref()
2781                .map(|plan| plan.has_signal())
2782                .unwrap_or(false);
2783        self.plan_execution_active
2784            .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
2785        let _plan_execution_guard = PlanExecutionGuard {
2786            flag: self.plan_execution_active.clone(),
2787        };
2788        let task_progress_before = if implement_current_plan {
2789            read_task_checklist_progress()
2790        } else {
2791            None
2792        };
2793        let current_plan_pass = if implement_current_plan {
2794            self.plan_execution_pass_depth
2795                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2796                + 1
2797        } else {
2798            0
2799        };
2800        let _plan_execution_pass_guard = implement_current_plan.then(|| PlanExecutionPassGuard {
2801            depth: self.plan_execution_pass_depth.clone(),
2802        });
2803        let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
2804
2805        // Seamless Search Handover: Transition to ASK mode if research is detected in AUTO.
2806        if self.workflow_mode == WorkflowMode::Auto
2807            && intent.primary_class == QueryIntentClass::Research
2808        {
2809            self.set_workflow_mode(WorkflowMode::Ask);
2810            let _ = tx
2811                .send(InferenceEvent::Thought(
2812                    "Seamless search detected: transitioning to investigation mode...".into(),
2813                ))
2814                .await;
2815        }
2816
2817        // ── /think / /no_think: reasoning budget toggle ──────────────────────
2818        if let Some(answer_kind) = intent.direct_answer {
2819            match answer_kind {
2820                DirectAnswerKind::About => {
2821                    let response = build_about_answer();
2822                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2823                        .await;
2824                    return Ok(());
2825                }
2826                DirectAnswerKind::LanguageCapability => {
2827                    let response = build_language_capability_answer();
2828                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2829                        .await;
2830                    return Ok(());
2831                }
2832                DirectAnswerKind::UnsafeWorkflowPressure => {
2833                    let response = build_unsafe_workflow_pressure_answer();
2834                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2835                        .await;
2836                    return Ok(());
2837                }
2838                DirectAnswerKind::SessionMemory => {
2839                    let response = build_session_memory_answer();
2840                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2841                        .await;
2842                    return Ok(());
2843                }
2844                DirectAnswerKind::RecoveryRecipes => {
2845                    let response = build_recovery_recipes_answer();
2846                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2847                        .await;
2848                    return Ok(());
2849                }
2850                DirectAnswerKind::McpLifecycle => {
2851                    let response = build_mcp_lifecycle_answer();
2852                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2853                        .await;
2854                    return Ok(());
2855                }
2856                DirectAnswerKind::AuthorizationPolicy => {
2857                    let response = build_authorization_policy_answer();
2858                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2859                        .await;
2860                    return Ok(());
2861                }
2862                DirectAnswerKind::ToolClasses => {
2863                    let response = build_tool_classes_answer();
2864                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2865                        .await;
2866                    return Ok(());
2867                }
2868                DirectAnswerKind::ToolRegistryOwnership => {
2869                    let response = build_tool_registry_ownership_answer();
2870                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2871                        .await;
2872                    return Ok(());
2873                }
2874                DirectAnswerKind::SessionResetSemantics => {
2875                    let response = build_session_reset_semantics_answer();
2876                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2877                        .await;
2878                    return Ok(());
2879                }
2880                DirectAnswerKind::ProductSurface => {
2881                    let response = build_product_surface_answer();
2882                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2883                        .await;
2884                    return Ok(());
2885                }
2886                DirectAnswerKind::ReasoningSplit => {
2887                    let response = build_reasoning_split_answer();
2888                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2889                        .await;
2890                    return Ok(());
2891                }
2892                DirectAnswerKind::Identity => {
2893                    let response = build_identity_answer();
2894                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2895                        .await;
2896                    return Ok(());
2897                }
2898                DirectAnswerKind::WorkflowModes => {
2899                    let response = build_workflow_modes_answer();
2900                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2901                        .await;
2902                    return Ok(());
2903                }
2904                DirectAnswerKind::GemmaNative => {
2905                    let response = build_gemma_native_answer();
2906                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2907                        .await;
2908                    return Ok(());
2909                }
2910                DirectAnswerKind::GemmaNativeSettings => {
2911                    let response = build_gemma_native_settings_answer();
2912                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2913                        .await;
2914                    return Ok(());
2915                }
2916                DirectAnswerKind::VerifyProfiles => {
2917                    let response = build_verify_profiles_answer();
2918                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2919                        .await;
2920                    return Ok(());
2921                }
2922                DirectAnswerKind::Toolchain => {
2923                    let lower = effective_user_input.to_lowercase();
2924                    let topic = if (lower.contains("voice output") || lower.contains("voice"))
2925                        && (lower.contains("lag")
2926                            || lower.contains("behind visible text")
2927                            || lower.contains("latency"))
2928                    {
2929                        "voice_latency_plan"
2930                    } else {
2931                        "all"
2932                    };
2933                    let response =
2934                        crate::tools::toolchain::describe_toolchain(&serde_json::json!({
2935                            "topic": topic,
2936                            "question": effective_user_input,
2937                        }))
2938                        .await
2939                        .unwrap_or_else(|e| format!("Error: {}", e));
2940                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2941                        .await;
2942                    return Ok(());
2943                }
2944                DirectAnswerKind::HostInspection => {
2945                    let topics = all_host_inspection_topics(&effective_user_input);
2946                    let response = if topics.len() >= 2 {
2947                        let mut combined = Vec::new();
2948                        for topic in topics {
2949                            let args =
2950                                host_inspection_args_from_prompt(topic, &effective_user_input);
2951                            let output = crate::tools::host_inspect::inspect_host(&args)
2952                                .await
2953                                .unwrap_or_else(|e| format!("Error (topic {topic}): {e}"));
2954                            combined.push(format!("# Topic: {topic}\n{output}"));
2955                        }
2956                        combined.join("\n\n---\n\n")
2957                    } else {
2958                        let topic = preferred_host_inspection_topic(&effective_user_input)
2959                            .unwrap_or("summary");
2960                        let args = host_inspection_args_from_prompt(topic, &effective_user_input);
2961                        crate::tools::host_inspect::inspect_host(&args)
2962                            .await
2963                            .unwrap_or_else(|e| format!("Error: {e}"))
2964                    };
2965
2966                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2967                        .await;
2968                    return Ok(());
2969                }
2970                DirectAnswerKind::ArchitectSessionResetPlan => {
2971                    let plan = build_architect_session_reset_plan();
2972                    let response = plan.to_markdown();
2973                    let _ = crate::tools::plan::save_plan_handoff(&plan);
2974                    self.session_memory.current_plan = Some(plan);
2975                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2976                        .await;
2977                    return Ok(());
2978                }
2979            }
2980        }
2981
2982        if matches!(
2983            self.workflow_mode,
2984            WorkflowMode::Ask | WorkflowMode::ReadOnly
2985        ) && looks_like_mutation_request(&effective_user_input)
2986        {
2987            let response = build_mode_redirect_answer(self.workflow_mode);
2988            self.history.push(ChatMessage::user(&effective_user_input));
2989            self.history.push(ChatMessage::assistant_text(&response));
2990            self.transcript.log_user(&transcript_user_input);
2991            self.transcript.log_agent(&response);
2992            for chunk in chunk_text(&response, 8) {
2993                if !chunk.is_empty() {
2994                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
2995                }
2996            }
2997            let _ = tx.send(InferenceEvent::Done).await;
2998            self.trim_history(80);
2999            self.refresh_session_memory();
3000            self.save_session();
3001            return Ok(());
3002        }
3003
3004        if user_input.trim() == "/think" {
3005            self.think_mode = Some(true);
3006            for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
3007                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3008            }
3009            let _ = tx.send(InferenceEvent::Done).await;
3010            return Ok(());
3011        }
3012        if user_input.trim() == "/no_think" {
3013            self.think_mode = Some(false);
3014            for chunk in chunk_text(
3015                "Think mode: OFF — fast mode enabled (no chain-of-thought).",
3016                8,
3017            ) {
3018                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3019            }
3020            let _ = tx.send(InferenceEvent::Done).await;
3021            return Ok(());
3022        }
3023
3024        // ── /pin: add file to active context ────────────────────────────────
3025        if user_input.trim_start().starts_with("/pin ") {
3026            let path = user_input.trim_start()[5..].trim();
3027            match std::fs::read_to_string(path) {
3028                Ok(content) => {
3029                    self.pinned_files
3030                        .lock()
3031                        .await
3032                        .insert(path.to_string(), content);
3033                    let msg = format!(
3034                        "Pinned: {} — this file is now locked in model context.",
3035                        path
3036                    );
3037                    for chunk in chunk_text(&msg, 8) {
3038                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
3039                    }
3040                }
3041                Err(e) => {
3042                    let _ = tx
3043                        .send(InferenceEvent::Error(format!(
3044                            "Failed to pin {}: {}",
3045                            path, e
3046                        )))
3047                        .await;
3048                }
3049            }
3050            let _ = tx.send(InferenceEvent::Done).await;
3051            return Ok(());
3052        }
3053
3054        // ── /unpin: remove file from active context ──────────────────────────
3055        if user_input.trim_start().starts_with("/unpin ") {
3056            let path = user_input.trim_start()[7..].trim();
3057            if self.pinned_files.lock().await.remove(path).is_some() {
3058                let msg = format!("Unpinned: {} — file removed from active context.", path);
3059                for chunk in chunk_text(&msg, 8) {
3060                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3061                }
3062            } else {
3063                let _ = tx
3064                    .send(InferenceEvent::Error(format!(
3065                        "File {} was not pinned.",
3066                        path
3067                    )))
3068                    .await;
3069            }
3070            let _ = tx.send(InferenceEvent::Done).await;
3071            return Ok(());
3072        }
3073
3074        // ── Normal processing ───────────────────────────────────────────────
3075
3076        // Ensure MCP is initialized and tools are discovered for this turn.
3077        if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
3078            if let Some(root) = extract_sovereign_scaffold_root(&effective_user_input) {
3079                if std::fs::create_dir_all(&root).is_ok() {
3080                    let targets = default_sovereign_scaffold_targets(&effective_user_input);
3081                    let _ = seed_sovereign_scaffold_files(&root, &targets);
3082                    let plan = build_sovereign_scaffold_handoff(&effective_user_input, &targets);
3083                    let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &plan);
3084                    let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
3085                    let _ = write_sovereign_handoff_markdown(&root, &effective_user_input, &plan);
3086                    self.pending_teleport_handoff = None;
3087                    self.latest_target_dir = Some(root.to_string_lossy().to_string());
3088                    let response = format!(
3089                        "Created the sovereign project root at `{}` and wrote a local handoff. Teleporting now so the next session can continue implementation inside that project.",
3090                        root.display()
3091                    );
3092                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
3093                        .await;
3094                    return Ok(());
3095                }
3096            }
3097        }
3098
3099        let tiny_context_mode = self.engine.current_context_length() <= 8_192;
3100        let mut base_prompt = self.engine.build_system_prompt(
3101            self.snark,
3102            self.chaos,
3103            self.brief,
3104            self.professional,
3105            &self.tools,
3106            self.reasoning_history.as_deref(),
3107            &mcp_tools,
3108        );
3109        if !tiny_context_mode {
3110            if let Some(hint) = &config.context_hint {
3111                if !hint.trim().is_empty() {
3112                    base_prompt.push_str(&format!(
3113                        "\n\n# Project Context (from .hematite/settings.json)\n{}",
3114                        hint
3115                    ));
3116                }
3117            }
3118            if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
3119                &crate::tools::file_ops::workspace_root(),
3120            ) {
3121                base_prompt.push_str(&format!("\n\n{}", profile_block));
3122            }
3123            if let Some(strategy_block) =
3124                crate::agent::workspace_profile::profile_strategy_prompt_block(
3125                    &crate::tools::file_ops::workspace_root(),
3126                )
3127            {
3128                base_prompt.push_str(&format!("\n\n{}", strategy_block));
3129            }
3130            // L1: inject hot-files block if available (persists across sessions via vein.db).
3131            if let Some(ref l1) = self.l1_context {
3132                base_prompt.push_str(&format!("\n\n{}", l1));
3133            }
3134            if let Some(ref repo_map_block) = self.repo_map {
3135                base_prompt.push_str(&format!("\n\n{}", repo_map_block));
3136            }
3137        }
3138        let grounded_trace_mode = intent.grounded_trace_mode
3139            || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
3140        let capability_mode =
3141            intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
3142        let toolchain_mode =
3143            intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
3144        // Embedding-based intent veto: when the keyword router says diagnostic,
3145        // ask nomic-embed whether the query is actually conversational/advisory.
3146        // Only fires when keyword routing would have triggered HOST INSPECTION MODE.
3147        // Falls back to the keyword result if the embed model is unavailable or slow.
3148        let host_inspection_mode = if intent.host_inspection_mode {
3149            let api_url = self.engine.base_url.clone();
3150            let query = effective_user_input.clone();
3151            let embed_class = tokio::time::timeout(
3152                std::time::Duration::from_millis(600),
3153                crate::agent::intent_embed::classify_intent(&query, &api_url),
3154            )
3155            .await
3156            .unwrap_or(crate::agent::intent_embed::IntentClass::Ambiguous);
3157            !matches!(
3158                embed_class,
3159                crate::agent::intent_embed::IntentClass::Advisory
3160            )
3161        } else {
3162            false
3163        };
3164        let maintainer_workflow_mode = intent.maintainer_workflow_mode
3165            || preferred_maintainer_workflow(&effective_user_input).is_some();
3166        let research_mode = intent.primary_class == QueryIntentClass::Research;
3167        let fix_plan_mode =
3168            preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
3169        let architecture_overview_mode = intent.architecture_overview_mode;
3170        let capability_needs_repo = intent.capability_needs_repo;
3171        let mut system_msg = build_system_with_corrections(
3172            &base_prompt,
3173            &self.correction_hints,
3174            &self.gpu_state,
3175            &self.git_state,
3176            &config,
3177        );
3178        if !tiny_context_mode && research_mode {
3179            system_msg.push_str(
3180                "\n\n# RESEARCH MODE\n\
3181                 This turn is an investigation into external technical information.\n\
3182                 Prioritize using the `research_web` tool to find the most current and authoritative data.\n\
3183                 When providing information, ground your answer in the search results and cite your sources if possible.\n\
3184                 If the user's question involves specific versions or recent releases (e.g., Rust compiler), use the web to verify the exact state.\n"
3185            );
3186        }
3187        if tiny_context_mode {
3188            system_msg.push_str(
3189                "\n\n# TINY CONTEXT TURN MODE\n\
3190                 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
3191            );
3192        }
3193        if !tiny_context_mode && grounded_trace_mode {
3194            system_msg.push_str(
3195                "\n\n# GROUNDED TRACE MODE\n\
3196                 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
3197                 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
3198                 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
3199                 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
3200                 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
3201                 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
3202                 Do not invent names such as synthetic channels or subsystems.\n\
3203                 If a detail is not verified from the code or tool output, say `uncertain`.\n\
3204                For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
3205            );
3206        }
3207        if !tiny_context_mode && capability_mode {
3208            // Consolidated: Capability instructions handled by prompt.rs
3209        }
3210        if !tiny_context_mode && toolchain_mode {
3211            // Consolidated: Toolchain instructions handled by prompt.rs
3212        }
3213        if !tiny_context_mode && host_inspection_mode {
3214            // Consolidated: Host Inspection rules handled by prompt.rs
3215        }
3216        if !tiny_context_mode && fix_plan_mode {
3217            system_msg.push_str(
3218                "\n\n# FIX PLAN MODE\n\
3219                 This turn is a workstation remediation question, not just a diagnosis question.\n\
3220                 Call `inspect_host` with `topic=fix_plan` first.\n\
3221                 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
3222                 Keep the answer grounded, stepwise, and approval-aware.\n"
3223            );
3224        }
3225        if !tiny_context_mode && maintainer_workflow_mode {
3226            system_msg.push_str(
3227                "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
3228                 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
3229                 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
3230                 Use workflow `clean` for cleanup, workflow `package_windows` for rebuilding the local portable or installer, and workflow `release` for the normal version bump/tag/push/publish flow.\n\
3231                 Do not treat this as a generic current-workspace script runner. Only fall back to raw `shell` if the user asks for a script or command outside those Hematite maintainer workflows.\n"
3232            );
3233        }
3234        // Consolidated: Workspace Workflow rules handled by prompt.rs
3235
3236        if !tiny_context_mode && architecture_overview_mode {
3237            system_msg.push_str(
3238                "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
3239                 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
3240                 Do not call `auto_pin_context` or `list_pinned` in read-only analysis. Avoid broad `read_file` calls unless the user explicitly asks for implementation detail in one named file.\n\
3241                 Preserve grounded tool output rather than restyling it into a larger answer.\n"
3242            );
3243        }
3244
3245        // ── Inject Pinned Files (Context Locking) ───────────────────────────
3246        system_msg.push_str(&format!(
3247            "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
3248            self.workflow_mode.label()
3249        ));
3250        if tiny_context_mode {
3251            system_msg
3252                .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
3253        } else {
3254        }
3255        if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
3256            system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
3257            system_msg.push_str(architect_handoff_contract());
3258            system_msg.push('\n');
3259        }
3260        if !tiny_context_mode && is_scaffold_request(&effective_user_input) {
3261            system_msg.push_str(scaffold_protocol());
3262        }
3263        if !tiny_context_mode && implement_current_plan {
3264            system_msg.push_str(
3265                "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
3266                 The user explicitly asked you to implement the current saved plan.\n\
3267                 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
3268                 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
3269                 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
3270                 If a built-in workspace read tool gives you enough context, your next step should be mutation or a concrete blocking question, not another summary.\n",
3271            );
3272            if let Some(plan) = self.session_memory.current_plan.as_ref() {
3273                if !plan.target_files.is_empty() {
3274                    system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
3275                    for path in &plan.target_files {
3276                        system_msg.push_str(&format!("- {}\n", path));
3277                    }
3278                }
3279            }
3280        }
3281        if !tiny_context_mode {
3282            let pinned = self.pinned_files.lock().await;
3283            if !pinned.is_empty() {
3284                system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
3285                system_msg.push_str("The following files are locked in your active memory for prioritized reference.\n\n");
3286                for (path, content) in pinned.iter() {
3287                    system_msg.push_str(&format!("## FILE: {}\n```\n{}\n```\n\n", path, content));
3288                }
3289            }
3290        }
3291        if !tiny_context_mode {
3292            self.append_session_handoff(&mut system_msg);
3293        }
3294        // ── Inject TASK.md Visibility ────────────────────────────────────────
3295        let mut final_system_msg = if self.workflow_mode.is_chat() {
3296            self.build_chat_system_prompt()
3297        } else {
3298            system_msg
3299        };
3300
3301        if !tiny_context_mode
3302            && matches!(self.workflow_mode, WorkflowMode::Code | WorkflowMode::Auto)
3303        {
3304            let task_path = std::path::Path::new(".hematite/TASK.md");
3305            if task_path.exists() {
3306                if let Ok(content) = std::fs::read_to_string(task_path) {
3307                    let snippet = if content.lines().count() > 50 {
3308                        content.lines().take(50).collect::<Vec<_>>().join("\n")
3309                            + "\n... (truncated)"
3310                    } else {
3311                        content
3312                    };
3313                    final_system_msg.push_str("\n\n# CURRENT TASK STATUS (.hematite/TASK.md)\n");
3314                    final_system_msg.push_str("Update this file via `edit_file` to check off `[x]` items as you complete them.\n");
3315                    final_system_msg.push_str("```markdown\n");
3316                    final_system_msg.push_str(&snippet);
3317                    final_system_msg.push_str("\n```\n");
3318                }
3319            }
3320        }
3321
3322        let system_msg = final_system_msg;
3323        if self.history.is_empty() || self.history[0].role != "system" {
3324            self.history.insert(0, ChatMessage::system(&system_msg));
3325        } else {
3326            self.history[0] = ChatMessage::system(&system_msg);
3327        }
3328
3329        // Ensure a clean state for the new turn.
3330        self.cancel_token
3331            .store(false, std::sync::atomic::Ordering::SeqCst);
3332
3333        // [Official Gemma-4 Spec] Purge reasoning history for new user turns.
3334        // History from previous turns must not be fed back into the prompt to prevent duplication.
3335        self.reasoning_history = None;
3336
3337        let is_gemma =
3338            crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
3339        let user_content = match self.think_mode {
3340            Some(true) => format!("/think\n{}", effective_user_input),
3341            Some(false) => format!("/no_think\n{}", effective_user_input),
3342            // For non-Gemma models (Qwen etc.) default to /think so the model uses
3343            // hybrid thinking — it decides how much reasoning each turn needs.
3344            // Gemma handles reasoning via <|think|> in the system prompt instead.
3345            // Chat mode and quick tool calls skip /think — fast direct answers.
3346            None if !is_gemma
3347                && !self.workflow_mode.is_chat()
3348                && !is_quick_tool_request(&effective_user_input) =>
3349            {
3350                format!("/think\n{}", effective_user_input)
3351            }
3352            None => effective_user_input.clone(),
3353        };
3354        if let Some(image) = user_turn.attached_image.as_ref() {
3355            let image_url =
3356                crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
3357                    .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
3358            self.history
3359                .push(ChatMessage::user_with_image(&user_content, &image_url));
3360        } else {
3361            self.history.push(ChatMessage::user(&user_content));
3362        }
3363        self.transcript.log_user(&transcript_user_input);
3364
3365        // Incremental re-index and Vein context injection. Ordinary chat mode
3366        // still skips repo-snippet noise, but docs-only workspaces and explicit
3367        // session-recall prompts should keep Vein memory available.
3368        let vein_docs_only = self.vein_docs_only_mode();
3369        let allow_vein_context = !self.workflow_mode.is_chat()
3370            || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
3371        let (vein_context, vein_paths) = if allow_vein_context {
3372            self.refresh_vein_index();
3373            let _ = tx
3374                .send(InferenceEvent::VeinStatus {
3375                    file_count: self.vein.file_count(),
3376                    embedded_count: self.vein.embedded_chunk_count(),
3377                    docs_only: vein_docs_only,
3378                })
3379                .await;
3380            match self.build_vein_context(&effective_user_input) {
3381                Some((ctx, paths)) => (Some(ctx), paths),
3382                None => (None, Vec::new()),
3383            }
3384        } else {
3385            (None, Vec::new())
3386        };
3387        if !vein_paths.is_empty() {
3388            let _ = tx
3389                .send(InferenceEvent::VeinContext { paths: vein_paths })
3390                .await;
3391        }
3392
3393        // Route: pick fast vs think model based on the complexity of this request.
3394        let routed_model = route_model(
3395            &effective_user_input,
3396            effective_fast.as_deref(),
3397            effective_think.as_deref(),
3398        )
3399        .map(|s| s.to_string());
3400
3401        let mut loop_intervention: Option<String> = None;
3402
3403        // ── Harness pre-run: multi-topic host inspection ─────────────────────
3404        // When the user asks for 2+ distinct inspect_host topics in one message,
3405        // run them all here and inject the combined results as a loop_intervention
3406        // so the model receives data instead of having to orchestrate tool calls.
3407        // This prevents the model from collapsing multiple topics into a generic
3408        // one, burning the tool loop budget, or retrying via shell.
3409        {
3410            let topics = all_host_inspection_topics(&effective_user_input);
3411            if topics.len() >= 2 {
3412                let _ = tx
3413                    .send(InferenceEvent::Thought(format!(
3414                        "Harness pre-run: {} host inspection topics detected — running all before model turn.",
3415                        topics.len()
3416                    )))
3417                    .await;
3418
3419                let topic_list = topics.join(", ");
3420                let mut combined = format!(
3421                    "## HARNESS PRE-RUN RESULTS\n\
3422                     The harness already ran inspect_host for the following topics: {topic_list}.\n\
3423                     Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
3424                );
3425
3426                let mut tool_calls = Vec::new();
3427                let mut tool_msgs = Vec::new();
3428
3429                for topic in &topics {
3430                    let call_id = format!("prerun_{topic}");
3431                    let mut args_val =
3432                        host_inspection_args_from_prompt(topic, &effective_user_input);
3433                    args_val
3434                        .as_object_mut()
3435                        .unwrap()
3436                        .insert("max_entries".to_string(), Value::from(20));
3437                    let args_str = serde_json::to_string(&args_val).unwrap_or_default();
3438
3439                    tool_calls.push(crate::agent::inference::ToolCallResponse {
3440                        id: call_id.clone(),
3441                        call_type: "function".to_string(),
3442                        function: crate::agent::inference::ToolCallFn {
3443                            name: "inspect_host".to_string(),
3444                            arguments: args_str,
3445                        },
3446                    });
3447
3448                    let label = format!("### inspect_host(topic=\"{topic}\")\n");
3449                    let _ = tx
3450                        .send(InferenceEvent::ToolCallStart {
3451                            id: call_id.clone(),
3452                            name: "inspect_host".to_string(),
3453                            args: format!("inspect host {topic}"),
3454                        })
3455                        .await;
3456
3457                    match crate::tools::host_inspect::inspect_host(&args_val).await {
3458                        Ok(out) => {
3459                            let _ = tx
3460                                .send(InferenceEvent::ToolCallResult {
3461                                    id: call_id.clone(),
3462                                    name: "inspect_host".to_string(),
3463                                    output: out.chars().take(300).collect::<String>() + "...",
3464                                    is_error: false,
3465                                })
3466                                .await;
3467                            combined.push_str(&label);
3468                            combined.push_str(&out);
3469                            combined.push_str("\n\n");
3470                            tool_msgs.push(ChatMessage::tool_result_for_model(
3471                                &call_id,
3472                                "inspect_host",
3473                                &out,
3474                                &self.engine.current_model(),
3475                            ));
3476                        }
3477                        Err(e) => {
3478                            let err_msg = format!("Error: {e}");
3479                            combined.push_str(&label);
3480                            combined.push_str(&err_msg);
3481                            combined.push_str("\n\n");
3482                            tool_msgs.push(ChatMessage::tool_result_for_model(
3483                                &call_id,
3484                                "inspect_host",
3485                                &err_msg,
3486                                &self.engine.current_model(),
3487                            ));
3488                        }
3489                    }
3490                }
3491
3492                // Add the simulated turn to history so the model sees it as context.
3493                self.history
3494                    .push(ChatMessage::assistant_tool_calls("", tool_calls));
3495                for msg in tool_msgs {
3496                    self.history.push(msg);
3497                }
3498
3499                loop_intervention = Some(combined);
3500            }
3501        }
3502
3503        // ── Research Pre-Run: force research_web for entity/knowledge queries ────
3504        // When the intent is classified as Research, the model often skips the
3505        // tool call and hallucinates from training data. To prevent this, we
3506        // execute research_web automatically and inject the results so the model
3507        // has grounded web data before it even starts generating.
3508        if loop_intervention.is_none() && research_mode {
3509            // Extract a clean search query from the user input.
3510            let search_query = effective_user_input
3511                .to_lowercase()
3512                .replace("google ", "")
3513                .replace("search for ", "")
3514                .replace("look up ", "")
3515                .replace("lookup ", "")
3516                .trim()
3517                .to_string();
3518
3519            let _ = tx
3520                .send(InferenceEvent::Thought(
3521                    "Research pre-run: executing search before model turn to ground the answer..."
3522                        .into(),
3523                ))
3524                .await;
3525
3526            let call_id = "prerun_research".to_string();
3527            let args = serde_json::json!({ "query": search_query });
3528
3529            let _ = tx
3530                .send(InferenceEvent::ToolCallStart {
3531                    id: call_id.clone(),
3532                    name: "research_web".to_string(),
3533                    args: format!("research_web: {}", search_query),
3534                })
3535                .await;
3536
3537            match crate::tools::research::execute_search(&args, config.searx_url.clone()).await {
3538                Ok(results)
3539                    if !results.is_empty() && !results.contains("No search results found") =>
3540                {
3541                    let _ = tx
3542                        .send(InferenceEvent::ToolCallResult {
3543                            id: call_id.clone(),
3544                            name: "research_web".to_string(),
3545                            output: results.chars().take(300).collect::<String>() + "...",
3546                            is_error: false,
3547                        })
3548                        .await;
3549
3550                    // Inject tool call + result into history so the model sees it.
3551                    self.history.push(ChatMessage::assistant_tool_calls(
3552                        "",
3553                        vec![crate::agent::inference::ToolCallResponse {
3554                            id: call_id.clone(),
3555                            call_type: "function".to_string(),
3556                            function: crate::agent::inference::ToolCallFn {
3557                                name: "research_web".to_string(),
3558                                arguments: serde_json::to_string(&args).unwrap_or_default(),
3559                            },
3560                        }],
3561                    ));
3562                    self.history.push(ChatMessage::tool_result_for_model(
3563                        &call_id,
3564                        "research_web",
3565                        &results,
3566                        &self.engine.current_model(),
3567                    ));
3568
3569                    loop_intervention = Some(format!(
3570                        "## RESEARCH PRE-RUN RESULTS\n\
3571                         The harness already ran `research_web` for your query.\n\
3572                         Use the search results above to answer the user's question with grounded, factual information.\n\
3573                         Do NOT re-run `research_web` unless you need additional detail.\n\
3574                         Do NOT hallucinate or guess — base your answer entirely on the search results.\n"
3575                    ));
3576                }
3577                Ok(_) | Err(_) => {
3578                    // Search returned empty or failed — let the model try on its own.
3579                    let _ = tx
3580                        .send(InferenceEvent::ToolCallResult {
3581                            id: call_id.clone(),
3582                            name: "research_web".to_string(),
3583                            output: "No results found — model will attempt its own search.".into(),
3584                            is_error: true,
3585                        })
3586                        .await;
3587                }
3588            }
3589        }
3590
3591        // ── Computation Integrity: nudge model toward run_code for precise math ──
3592        // When the query involves exact numeric computation (hashes, financial math,
3593        // statistics, date arithmetic, unit conversions, algorithmic checks), inject
3594        // a brief pre-turn reminder so the model reaches for run_code instead of
3595        // answering from training-data memory. Only fires when no harness pre-run
3596        // already set a loop_intervention.
3597        if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
3598            loop_intervention = Some(
3599                "COMPUTATION INTEGRITY NOTICE: This query involves precise numeric computation. \
3600                 Do NOT answer from training-data memory — memory answers for math are guesses. \
3601                 Use `run_code` to compute the real result and return the actual output. \
3602                 IMPORTANT: the `run_code` tool defaults to JavaScript (Deno). \
3603                 If you write Python code, you MUST pass `language: \"python\"` explicitly. \
3604                 If you write JavaScript/TypeScript, omit the language field or pass `language: \"javascript\"`. \
3605                 Write the code, run it, return the result."
3606                    .to_string(),
3607            );
3608        }
3609
3610        // ── Native Tool Mandate: nudge model toward create_directory/write_file for local mutations ──
3611        if loop_intervention.is_none() && intent.surgical_filesystem_mode {
3612            loop_intervention = Some(
3613                "NATIVE TOOL MANDATE: Your request involves local directory or file creation. \
3614                 You MUST use Hematite's native surgical tools (`create_directory`, `write_file`, `update_file`, `patch_hunk`). \
3615                 External `mcp__filesystem__*` mutation tools are BLOCKED for these actions and will fail. \
3616                 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for 100% path accuracy."
3617                    .to_string(),
3618            );
3619        }
3620
3621        // ── Auto-Architect: complex scaffold requests in /auto get a plan-first nudge ──
3622        // When the user asks for a multi-file build in /auto mode, instruct the model
3623        // to draft a PLAN.md blueprint first. The plan_drafted_this_turn gate at the
3624        // end of run_turn will then fire the Y/N approval and chain into implementation.
3625        if loop_intervention.is_none()
3626            && self.workflow_mode == WorkflowMode::Auto
3627            && is_scaffold_request(&effective_user_input)
3628            && !implement_current_plan
3629        {
3630            loop_intervention = Some(
3631                "AUTO-ARCHITECT: This request involves building multiple files (a scaffold). \
3632                 Before implementing, draft a concise blueprint to `.hematite/PLAN.md` using `write_file`. \
3633                 The blueprint should list:\n\
3634                 1. The target directory path\n\
3635                 2. Each file to create (with a one-line description of its purpose)\n\
3636                 3. Key design decisions (e.g. color scheme, layout approach)\n\n\
3637                 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for path accuracy.\n\
3638                 After writing the PLAN.md, respond with a brief summary of what you planned. \
3639                 Do NOT start implementing yet — just write the plan."
3640                    .to_string(),
3641            );
3642        }
3643
3644        let mut implementation_started = false;
3645        let mut plan_drafted_this_turn = false;
3646        let mut non_mutating_plan_steps = 0usize;
3647        let non_mutating_plan_soft_cap = 5usize;
3648        let non_mutating_plan_hard_cap = 8usize;
3649        let mut overview_runtime_trace: Option<String> = None;
3650
3651        // Safety cap – never spin forever on a broken model.
3652        let max_iters = 25;
3653        let mut consecutive_errors = 0;
3654        let mut empty_cleaned_nudges = 0u8;
3655        let mut first_iter = true;
3656        let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
3657        // Track identical tool results within this turn to detect logical loops.
3658        let _result_counts: std::collections::HashMap<String, usize> =
3659            std::collections::HashMap::new();
3660        // Track the count of identical (name, args) calls to detect infinite tool loops.
3661        let mut repeat_counts: std::collections::HashMap<String, usize> =
3662            std::collections::HashMap::new();
3663        let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
3664            std::collections::HashMap::new();
3665        let mut successful_read_targets: std::collections::HashSet<String> =
3666            std::collections::HashSet::new();
3667        // (path, offset) pairs — catches repeated reads at the same non-zero offset.
3668        let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
3669            std::collections::HashSet::new();
3670        let mut successful_grep_targets: std::collections::HashSet<String> =
3671            std::collections::HashSet::new();
3672        let mut no_match_grep_targets: std::collections::HashSet<String> =
3673            std::collections::HashSet::new();
3674        let mut broad_grep_targets: std::collections::HashSet<String> =
3675            std::collections::HashSet::new();
3676        let mut sovereign_task_root: Option<String> = None;
3677        let mut sovereign_scaffold_targets: std::collections::BTreeSet<String> =
3678            std::collections::BTreeSet::new();
3679        let mut turn_mutated_paths: std::collections::BTreeSet<String> =
3680            std::collections::BTreeSet::new();
3681        let mut mutation_counts_by_path: std::collections::HashMap<String, usize> =
3682            std::collections::HashMap::new();
3683        let mut frontend_polish_intervention_emitted = false;
3684        let mut visible_closeout_emitted = false;
3685
3686        // Track the index of the message that started THIS turn, so compaction doesn't summarize it.
3687        let mut turn_anchor = self.history.len().saturating_sub(1);
3688
3689        for _iter in 0..max_iters {
3690            let mut mutation_occurred = false;
3691            // Priority Check: External Cancellation (via Esc key in TUI)
3692            if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
3693                self.cancel_token
3694                    .store(false, std::sync::atomic::Ordering::SeqCst);
3695                let _ = tx
3696                    .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
3697                    .await;
3698                let _ = tx.send(InferenceEvent::Done).await;
3699                return Ok(());
3700            }
3701
3702            // ── Intelligence Surge: Proactive Compaction Check ──────────────────────
3703            if self
3704                .compact_history_if_needed(&tx, Some(turn_anchor))
3705                .await?
3706            {
3707                // After compaction, history is [system, summary, turn_anchor, ...]
3708                // The new turn_anchor is index 2.
3709                turn_anchor = 2;
3710            }
3711
3712            // On the first iteration inject Vein context into the system message.
3713            // Subsequent iterations use the plain slice — tool results are now in
3714            // history so Vein context would be redundant.
3715            let inject_vein = first_iter && !implement_current_plan;
3716            let messages = if implement_current_plan {
3717                first_iter = false;
3718                self.context_window_slice_from(turn_anchor)
3719            } else {
3720                first_iter = false;
3721                self.context_window_slice()
3722            };
3723
3724            // Use the canonical system prompt from history[0] which was built
3725            // by InferenceEngine::build_system_prompt() + build_system_with_corrections()
3726            // and includes GPU state, git context, permissions, and instruction files.
3727            let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
3728                // Gemma 4 handles multiple system messages natively.
3729                // Standard models (Qwen, etc.) reject a second system message — merge into history[0].
3730                if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
3731                    let mut msgs = vec![self.history[0].clone()];
3732                    msgs.push(ChatMessage::system(&intervention));
3733                    msgs
3734                } else {
3735                    let merged =
3736                        format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
3737                    vec![ChatMessage::system(&merged)]
3738                }
3739            } else {
3740                vec![self.history[0].clone()]
3741            };
3742
3743            // Inject Vein context into the system message on the first iteration.
3744            // Vein results are merged in the same way as loop_intervention so standard
3745            // models (Qwen etc.) only ever see one system message.
3746            if inject_vein {
3747                if let Some(ref ctx) = vein_context.as_ref() {
3748                    if crate::agent::inference::is_hematite_native_model(
3749                        &self.engine.current_model(),
3750                    ) {
3751                        prompt_msgs.push(ChatMessage::system(ctx));
3752                    } else {
3753                        let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
3754                        prompt_msgs[0] = ChatMessage::system(&merged);
3755                    }
3756                }
3757            }
3758            if let Some(root) = sovereign_task_root.as_ref() {
3759                let sovereign_root_instruction = format!(
3760                    "EFFECTIVE TASK ROOT: This sovereign scaffold turn is now rooted at:\n\
3761                     `{root}`\n\n\
3762                     Treat that directory as the active project root for the rest of this turn. \
3763                     All reads, writes, verification, and summaries must stay scoped to that root. \
3764                     Ignore unrelated repo context such as `./src` unless the user explicitly asks about it. \
3765                     Keep building within this sovereign root instead of reasoning from the original workspace."
3766                );
3767                if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
3768                    prompt_msgs.push(ChatMessage::system(&sovereign_root_instruction));
3769                } else {
3770                    let merged = format!(
3771                        "{}\n\n{}",
3772                        prompt_msgs[0].content.as_str(),
3773                        sovereign_root_instruction
3774                    );
3775                    prompt_msgs[0] = ChatMessage::system(&merged);
3776                }
3777            }
3778            prompt_msgs.extend(messages);
3779            if let Some(budget_note) =
3780                enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
3781            {
3782                self.emit_operator_checkpoint(
3783                    &tx,
3784                    OperatorCheckpointState::BudgetReduced,
3785                    budget_note,
3786                )
3787                .await;
3788                let recipe = plan_recovery(
3789                    RecoveryScenario::PromptBudgetPressure,
3790                    &self.recovery_context,
3791                );
3792                self.emit_recovery_recipe_summary(
3793                    &tx,
3794                    recipe.recipe.scenario.label(),
3795                    compact_recovery_plan_summary(&recipe),
3796                )
3797                .await;
3798            }
3799            self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
3800                .await;
3801
3802            let turn_tools = if yolo {
3803                // FORCE NLG ONLY: Hide all tools to ensure a plain text summary.
3804                Vec::new()
3805            } else if intent.sovereign_mode {
3806                self.tools
3807                    .iter()
3808                    .filter(|t| {
3809                        t.function.name != "shell" && t.function.name != "run_workspace_workflow"
3810                    })
3811                    .cloned()
3812                    .collect::<Vec<_>>()
3813            } else {
3814                self.tools.clone()
3815            };
3816
3817            let (mut text, mut tool_calls, usage, finish_reason) = match self
3818                .engine
3819                .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref())
3820                .await
3821            {
3822                Ok(result) => result,
3823                Err(e) => {
3824                    let class = classify_runtime_failure(&e);
3825                    if should_retry_runtime_failure(class) {
3826                        if self.recovery_context.consume_transient_retry() {
3827                            let label = match class {
3828                                RuntimeFailureClass::ProviderDegraded => "provider_degraded",
3829                                _ => "empty_model_response",
3830                            };
3831                            self.transcript.log_system(&format!(
3832                                "Automatic provider recovery triggered: {}",
3833                                e.trim()
3834                            ));
3835                            self.emit_recovery_recipe_summary(
3836                                &tx,
3837                                label,
3838                                compact_runtime_recovery_summary(class),
3839                            )
3840                            .await;
3841                            let _ = tx
3842                                .send(InferenceEvent::ProviderStatus {
3843                                    state: ProviderRuntimeState::Recovering,
3844                                    summary: compact_runtime_recovery_summary(class).into(),
3845                                })
3846                                .await;
3847                            self.emit_operator_checkpoint(
3848                                &tx,
3849                                OperatorCheckpointState::RecoveringProvider,
3850                                compact_runtime_recovery_summary(class),
3851                            )
3852                            .await;
3853                            continue;
3854                        }
3855                    }
3856
3857                    self.emit_runtime_failure(&tx, class, &e).await;
3858                    break;
3859                }
3860            };
3861            self.emit_provider_live(&tx).await;
3862
3863            // ── LOOP GUARD: Reasoning Collapse Detection ──────────────────────────
3864            // If the model returns no text AND no tool calls, but has a massive
3865            // block of hidden reasoning (often seen as infinite newlines in small models),
3866            // trigger a safety stop to prevent token drain.
3867            if text.is_none() && tool_calls.is_none() {
3868                if let Some(reasoning) = usage.as_ref().and_then(|u| {
3869                    if u.completion_tokens > 2000 {
3870                        Some(u.completion_tokens)
3871                    } else {
3872                        None
3873                    }
3874                }) {
3875                    self.emit_operator_checkpoint(
3876                        &tx,
3877                        OperatorCheckpointState::BlockedToolLoop,
3878                        format!(
3879                            "Reasoning collapse detected ({} tokens of empty output).",
3880                            reasoning
3881                        ),
3882                    )
3883                    .await;
3884                    break;
3885                }
3886            }
3887
3888            // Update TUI token counter with actual usage from LM Studio.
3889            if let Some(ref u) = usage {
3890                let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
3891            }
3892
3893            // Fallback safety net: if native tool markup leaked past the inference-layer
3894            // extractor, recover it here instead of treating it as plain assistant text.
3895            if tool_calls
3896                .as_ref()
3897                .map(|calls| calls.is_empty())
3898                .unwrap_or(true)
3899            {
3900                if let Some(raw_text) = text.as_deref() {
3901                    let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
3902                    if !native_calls.is_empty() {
3903                        tool_calls = Some(native_calls);
3904                        let stripped =
3905                            crate::agent::inference::strip_native_tool_call_text(raw_text);
3906                        text = if stripped.trim().is_empty() {
3907                            None
3908                        } else {
3909                            Some(stripped)
3910                        };
3911                    }
3912                }
3913            }
3914
3915            // Treat empty tool_calls arrays (Some(vec![])) the same as None –
3916            // the model returned text only; an empty array causes an infinite loop.
3917            let tool_calls = tool_calls.filter(|c| !c.is_empty());
3918            let near_context_ceiling = usage
3919                .as_ref()
3920                .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
3921                .unwrap_or(false);
3922
3923            if let Some(calls) = tool_calls {
3924                let (calls, prune_trace_note) =
3925                    prune_architecture_trace_batch(calls, architecture_overview_mode);
3926                if let Some(note) = prune_trace_note {
3927                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3928                }
3929
3930                let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
3931                    calls,
3932                    self.workflow_mode.is_read_only(),
3933                    architecture_overview_mode,
3934                );
3935                if let Some(note) = prune_bloat_note {
3936                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3937                }
3938
3939                let (calls, prune_note) = prune_authoritative_tool_batch(
3940                    calls,
3941                    grounded_trace_mode,
3942                    &effective_user_input,
3943                );
3944                if let Some(note) = prune_note {
3945                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3946                }
3947
3948                let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
3949                if let Some(note) = prune_redir_note {
3950                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3951                }
3952
3953                let (calls, batch_note) = order_batch_reads_first(calls);
3954                if let Some(note) = batch_note {
3955                    let _ = tx.send(InferenceEvent::Thought(note)).await;
3956                }
3957
3958                if let Some(repeated_path) = calls
3959                    .iter()
3960                    .filter_map(|c| repeated_read_target(&c.function))
3961                    .find(|path| successful_read_targets.contains(path))
3962                {
3963                    let repeated_path = repeated_path.to_string();
3964
3965                    let err_msg = format!(
3966                        "Read discipline: You already read `{}` recently. Use `inspect_lines` on a specific window or `grep_files` to find content, then continue with your edit.",
3967                        repeated_path
3968                    );
3969                    let _ = tx
3970                        .clone()
3971                        .send(InferenceEvent::Token(format!("\n⚠️ {}\n", err_msg)))
3972                        .await;
3973                    let _ = tx
3974                        .clone()
3975                        .send(InferenceEvent::Thought(format!(
3976                            "Intervention: {}",
3977                            err_msg
3978                        )))
3979                        .await;
3980
3981                    // BREAK THE SILENT LOOP: Push hard errors for these tool calls individually.
3982                    // This forces the LLM to see the result and pivot in its next turn.
3983                    for call in &calls {
3984                        self.history.push(ChatMessage::tool_result_for_model(
3985                            &call.id,
3986                            &call.function.name,
3987                            &err_msg,
3988                            &self.engine.current_model(),
3989                        ));
3990                    }
3991                    self.emit_done_events(&tx).await;
3992                    return Ok(());
3993                }
3994
3995                if capability_mode
3996                    && !capability_needs_repo
3997                    && calls
3998                        .iter()
3999                        .all(|c| is_capability_probe_tool(&c.function.name))
4000                {
4001                    loop_intervention = Some(
4002                        "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
4003                         Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
4004                         Do not mention raw `mcp__*` names unless they are active and directly relevant."
4005                            .to_string(),
4006                    );
4007                    let _ = tx.clone()
4008                        .send(InferenceEvent::Thought(
4009                            "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
4010                                .into(),
4011                        ))
4012                        .await;
4013                    continue;
4014                }
4015
4016                // VOCAL AGENT: If the model provided reasoning alongside tools,
4017                // stream it to the SPECULAR panel now using the hardened extraction.
4018                let raw_content = text.as_deref().unwrap_or(" ");
4019
4020                if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
4021                    let _ = tx
4022                        .clone()
4023                        .send(InferenceEvent::Thought(thought.clone()))
4024                        .await;
4025                    // Reasoning is silent (hidden in SPECULAR only).
4026                    self.reasoning_history = Some(thought);
4027                }
4028
4029                // [Gemma-4 Protocol] Keep raw content (including thoughts) during tool loops.
4030                // Thoughts are only stripped before the 'final' user turn.
4031                let stored_tool_call_content = if implement_current_plan {
4032                    cap_output(raw_content, 1200)
4033                } else {
4034                    raw_content.to_string()
4035                };
4036                self.history.push(ChatMessage::assistant_tool_calls(
4037                    &stored_tool_call_content,
4038                    calls.clone(),
4039                ));
4040
4041                // ── LAYER 4: Parallel Tool Orchestration (Batching) ────────────────────
4042                let mut results = Vec::new();
4043                let gemma4_model =
4044                    crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
4045                let latest_user_prompt = self.latest_user_prompt();
4046                let mut seen_call_keys = std::collections::HashSet::new();
4047                let mut deduped_calls = Vec::new();
4048                for call in calls.clone() {
4049                    let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
4050                        &call.function.name,
4051                        &call.function.arguments,
4052                        gemma4_model,
4053                        latest_user_prompt,
4054                    );
4055
4056                    // --- HALLUCINATION SANITIZER ---
4057                    if normalized_name == "shell" || normalized_name == "run_workspace_workflow" {
4058                        let cmd_val = normalized_args
4059                            .get("command")
4060                            .or_else(|| normalized_args.get("workflow"));
4061
4062                        if let Some(cmd) = cmd_val.and_then(|v| v.as_str()) {
4063                            if cfg!(windows)
4064                                && (cmd.contains("/dev/")
4065                                    || cmd.contains("/etc/")
4066                                    || cmd.contains("/var/"))
4067                            {
4068                                let err_msg = "STRICT: You are attempting to use Linux system paths (/dev, /etc, /var) on a Windows host. This is a reasoning collapse. Use relative paths within your workspace only.";
4069                                let _ = tx
4070                                    .clone()
4071                                    .send(InferenceEvent::Token(format!("\n🚨 {}\n", err_msg)))
4072                                    .await;
4073                                let _ = tx
4074                                    .clone()
4075                                    .send(InferenceEvent::Thought(format!(
4076                                        "Panic blocked: {}",
4077                                        err_msg
4078                                    )))
4079                                    .await;
4080
4081                                // BREAK THE COLLAPSE: Push hard errors for all tool calls in this batch and end turn.
4082                                let mut err_results = Vec::new();
4083                                for c in &calls {
4084                                    err_results.push(ChatMessage::tool_result_for_model(
4085                                        &c.id,
4086                                        &c.function.name,
4087                                        err_msg,
4088                                        &self.engine.current_model(),
4089                                    ));
4090                                }
4091                                for res in err_results {
4092                                    self.history.push(res);
4093                                }
4094                                self.emit_done_events(&tx).await;
4095                                return Ok(());
4096                            }
4097
4098                            if is_natural_language_hallucination(cmd) {
4099                                let err_msg = format!(
4100                                    "HALLUCINATION BLOCKED: You tried to pass natural language ('{}') into a command field. \
4101                                     Commands must be literal executables (e.g. `npm install`, `mkdir path`). \
4102                                     Use the correct surgical tool (like `create_directory`) instead of overthinking.",
4103                                    cmd
4104                                );
4105                                let _ = tx
4106                                    .send(InferenceEvent::Thought(format!(
4107                                        "Sanitizer error: {}",
4108                                        err_msg
4109                                    )))
4110                                    .await;
4111                                results.push(ToolExecutionOutcome {
4112                                    call_id: call.id.clone(),
4113                                    tool_name: normalized_name.clone(),
4114                                    args: normalized_args.clone(),
4115                                    output: err_msg,
4116                                    is_error: true,
4117                                    blocked_by_policy: false,
4118                                    msg_results: Vec::new(),
4119                                    latest_target_dir: None,
4120                                    plan_drafted_this_turn: false,
4121                                    parsed_plan_handoff: None,
4122                                });
4123                                continue;
4124                            }
4125                        }
4126                    }
4127
4128                    let key = canonical_tool_call_key(&normalized_name, &normalized_args);
4129                    if seen_call_keys.insert(key) {
4130                        let repeat_guard_exempt = matches!(
4131                            normalized_name.as_str(),
4132                            "verify_build" | "git_commit" | "git_push"
4133                        );
4134                        if !repeat_guard_exempt {
4135                            if let Some(cached) = completed_tool_cache
4136                                .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
4137                            {
4138                                let _ = tx
4139                                    .send(InferenceEvent::Thought(
4140                                        "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
4141                                            .to_string(),
4142                                    ))
4143                                    .await;
4144                                loop_intervention = Some(format!(
4145                                    "STOP. You already called `{}` with identical arguments earlier in this turn and already have that result in conversation history. Do not call it again. Use the existing result to answer or choose a different next step.",
4146                                    cached.tool_name
4147                                ));
4148                                continue;
4149                            }
4150                        }
4151                        deduped_calls.push(call);
4152                    } else {
4153                        let _ = tx
4154                            .send(InferenceEvent::Thought(
4155                                "Duplicate tool call skipped: identical built-in invocation already ran this turn."
4156                                    .to_string(),
4157                            ))
4158                            .await;
4159                    }
4160                }
4161
4162                // Partition tool calls: Parallel Read vs Serial Mutating
4163                let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
4164                    .into_iter()
4165                    .partition(|c| is_parallel_safe(&c.function.name));
4166
4167                // 1. Concurrent Execution (ParallelRead)
4168                if !parallel_calls.is_empty() {
4169                    let mut tasks = Vec::new();
4170                    for call in parallel_calls {
4171                        let tx_clone = tx.clone();
4172                        let config_clone = config.clone();
4173                        // Carry the real call ID into the outcome
4174                        let call_with_id = call.clone();
4175                        tasks.push(self.process_tool_call(
4176                            call_with_id.function,
4177                            config_clone,
4178                            yolo,
4179                            tx_clone,
4180                            call_with_id.id,
4181                        ));
4182                    }
4183                    // Wait for all read-only tasks to complete simultaneously.
4184                    results.extend(futures::future::join_all(tasks).await);
4185                }
4186
4187                // 2. Sequential Execution (SerialMutating)
4188                let mut sovereign_bootstrap_complete = false;
4189
4190                for call in serial_calls {
4191                    let outcome = self
4192                        .process_tool_call(call.function, config.clone(), yolo, tx.clone(), call.id)
4193                        .await;
4194
4195                    if !outcome.is_error {
4196                        let tool_name = outcome.tool_name.as_str();
4197                        if matches!(
4198                            tool_name,
4199                            "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
4200                        ) {
4201                            if let Some(target) = action_target_path(tool_name, &outcome.args) {
4202                                let normalized_path = normalize_workspace_path(&target);
4203                                let rewrite_count = mutation_counts_by_path
4204                                    .entry(normalized_path.clone())
4205                                    .and_modify(|count| *count += 1)
4206                                    .or_insert(1);
4207
4208                                let is_frontend_asset = [
4209                                    ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx", ".vue",
4210                                    ".svelte",
4211                                ]
4212                                .iter()
4213                                .any(|ext| normalized_path.ends_with(ext));
4214
4215                                if is_frontend_asset && *rewrite_count >= 3 {
4216                                    frontend_polish_intervention_emitted = true;
4217                                    loop_intervention = Some(format!(
4218                                        "REWRITE LIMIT REACHED. You have updated `{}` {} times this turn. To prevent reasoning collapse, further rewrites to this file are blocked. \
4219                                         Please UPDATE `.hematite/TASK.md` to check off these completed steps, and response with a concise engineering summary of the implementation status.",
4220                                        normalized_path, rewrite_count
4221                                    ));
4222                                    results.push(outcome);
4223                                    let _ = tx.send(InferenceEvent::Thought("Frontend rewrite guard: block reached — prompting for task update and summary.".to_string())).await;
4224                                    break; // Terminate this turn's tool execution immediately.
4225                                } else if !frontend_polish_intervention_emitted
4226                                    && is_frontend_asset
4227                                    && *rewrite_count >= 2
4228                                {
4229                                    frontend_polish_intervention_emitted = true;
4230                                    loop_intervention = Some(format!(
4231                                        "STOP REWRITING. You have already written `{}` {} times. The current version is sufficient as a foundation. \
4232                                         Do NOT use `write_file` on this file again. Instead, check off your completed steps in `.hematite/TASK.md` and move on to the next file or provide your final summary.",
4233                                        normalized_path, rewrite_count
4234                                    ));
4235                                    results.push(outcome);
4236                                    let _ = tx.send(InferenceEvent::Thought("Frontend polish guard: repeated rewrite detected; prompting for progress log and next steps.".to_string())).await;
4237                                    break; // Terminate this turn's tool execution immediately.
4238                                }
4239                            }
4240                        }
4241                    }
4242
4243                    if !outcome.is_error
4244                        && intent.sovereign_mode
4245                        && is_scaffold_request(&effective_user_input)
4246                        && outcome.latest_target_dir.is_some()
4247                    {
4248                        sovereign_bootstrap_complete = true;
4249                    }
4250                    results.push(outcome);
4251                    if sovereign_bootstrap_complete {
4252                        let _ = tx
4253                            .send(InferenceEvent::Thought(
4254                                "Sovereign scaffold bootstrap complete: stopping this session after root setup so the resumed session can continue inside the new project."
4255                                    .to_string(),
4256                            ))
4257                            .await;
4258                        break;
4259                    }
4260                }
4261
4262                // 3. Collate Messages into History & UI
4263                let mut authoritative_tool_output: Option<String> = None;
4264                let mut blocked_policy_output: Option<String> = None;
4265                let mut recoverable_policy_intervention: Option<String> = None;
4266                let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
4267                let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
4268                    None;
4269                for res in results {
4270                    let call_id = res.call_id.clone();
4271                    let tool_name = res.tool_name.clone();
4272                    let final_output = res.output.clone();
4273                    let is_error = res.is_error;
4274                    for msg in res.msg_results {
4275                        self.history.push(msg);
4276                    }
4277
4278                    // Update State for Verification Loop
4279                    if let Some(path) = res.latest_target_dir {
4280                        if intent.sovereign_mode && sovereign_task_root.is_none() {
4281                            sovereign_task_root = Some(path.clone());
4282                            self.pending_teleport_handoff = Some(SovereignTeleportHandoff {
4283                                root: path.clone(),
4284                                plan: build_sovereign_scaffold_handoff(
4285                                    &effective_user_input,
4286                                    &sovereign_scaffold_targets,
4287                                ),
4288                            });
4289                            let _ = tx
4290                                .send(InferenceEvent::Thought(format!(
4291                                    "Sovereign scaffold root established at `{}`; rebinding project context there for the rest of this turn.",
4292                                    path
4293                                )))
4294                                .await;
4295                        }
4296                        self.latest_target_dir = Some(path);
4297                    }
4298
4299                    if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
4300                        if let Some(root) = sovereign_task_root.as_ref() {
4301                            if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
4302                                let resolved = crate::tools::file_ops::resolve_candidate(path);
4303                                let root_path = std::path::Path::new(root);
4304                                if let Ok(relative) = resolved.strip_prefix(root_path) {
4305                                    if !relative.as_os_str().is_empty() {
4306                                        sovereign_scaffold_targets
4307                                            .insert(relative.to_string_lossy().replace('\\', "/"));
4308                                    }
4309                                    self.pending_teleport_handoff =
4310                                        Some(SovereignTeleportHandoff {
4311                                            root: root.clone(),
4312                                            plan: build_sovereign_scaffold_handoff(
4313                                                &effective_user_input,
4314                                                &sovereign_scaffold_targets,
4315                                            ),
4316                                        });
4317                                }
4318                            }
4319                        }
4320                    }
4321                    if matches!(
4322                        tool_name.as_str(),
4323                        "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
4324                    ) {
4325                        mutation_occurred = true;
4326                        implementation_started = true;
4327                        if !is_error {
4328                            if let Some(target) = action_target_path(&tool_name, &res.args) {
4329                                turn_mutated_paths.insert(target);
4330                            }
4331                        }
4332                        // Heat tracking: bump L1 score for the edited file.
4333                        if !is_error {
4334                            let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
4335                            if !path.is_empty() {
4336                                self.vein.bump_heat(path);
4337                                self.l1_context = self.vein.l1_context();
4338                                // Compact stale read_file results for this path — the file
4339                                // just changed so old content is wrong and wastes context.
4340                                compact_stale_reads(&mut self.history, path);
4341                            }
4342                            // Refresh repo map so PageRank accounts for the new edit.
4343                            self.refresh_repo_map();
4344                        }
4345                    }
4346
4347                    if !is_error
4348                        && matches!(
4349                            tool_name.as_str(),
4350                            "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
4351                        )
4352                    {
4353                        // Internal mutation counts now handled early in sequential loop.
4354                    }
4355
4356                    if res.plan_drafted_this_turn {
4357                        plan_drafted_this_turn = true;
4358                    }
4359                    if let Some(plan) = res.parsed_plan_handoff.clone() {
4360                        self.session_memory.current_plan = Some(plan);
4361                    }
4362
4363                    if tool_name == "verify_build" {
4364                        self.record_session_verification(
4365                            !is_error
4366                                && (final_output.contains("BUILD OK")
4367                                    || final_output.contains("BUILD SUCCESS")
4368                                    || final_output.contains("BUILD OKAY")),
4369                            if is_error {
4370                                "Explicit verify_build failed."
4371                            } else {
4372                                "Explicit verify_build passed."
4373                            },
4374                        );
4375                    }
4376
4377                    // Update Repeat Guard
4378                    let call_key = format!(
4379                        "{}:{}",
4380                        tool_name,
4381                        serde_json::to_string(&res.args).unwrap_or_default()
4382                    );
4383                    let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
4384                    *repeat_count += 1;
4385
4386                    // Structured verification and commit tools are legitimately called multiple
4387                    // times in fix-verify loops.
4388                    let repeat_guard_exempt =
4389                        is_repeat_guard_exempt_tool_call(&tool_name, &res.args);
4390                    if *repeat_count >= 2 && !repeat_guard_exempt {
4391                        loop_intervention = Some(format!(
4392                            "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
4393                             Do not call it again. Either answer directly from what you already know, \
4394                             use a different tool or approach (e.g. if reading the same file, use grep or LSP symbols instead), \
4395                             or ask the user for clarification.",
4396                            tool_name, *repeat_count
4397                        ));
4398                        let _ = tx
4399                            .send(InferenceEvent::Thought(format!(
4400                                "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
4401                                tool_name, *repeat_count
4402                            )))
4403                            .await;
4404                    }
4405
4406                    if *repeat_count >= 3 && !repeat_guard_exempt {
4407                        self.emit_runtime_failure(
4408                            &tx,
4409                            RuntimeFailureClass::ToolLoop,
4410                            &format!(
4411                                "STRICT: You are stuck in a reasoning loop calling `{}`. \
4412                                STOP repeating this call. Switch to grounded filesystem tools \
4413                                (like `read_file`, `inspect_lines`, or `edit_file`) instead of \
4414                                attempting this workflow again.",
4415                                tool_name
4416                            ),
4417                        )
4418                        .await;
4419                        return Ok(());
4420                    }
4421
4422                    if is_error {
4423                        consecutive_errors += 1;
4424                    } else {
4425                        consecutive_errors = 0;
4426                    }
4427
4428                    if consecutive_errors >= 3 {
4429                        loop_intervention = Some(
4430                            "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
4431                             STOP all tool calls immediately. Analyze why your previous 3 calls failed \
4432                             (check for hallucinations or invalid arguments) and ask the user for \
4433                             clarification if you cannot proceed.".to_string()
4434                        );
4435                    }
4436
4437                    if consecutive_errors >= 4 {
4438                        self.emit_runtime_failure(
4439                            &tx,
4440                            RuntimeFailureClass::ToolLoop,
4441                            "Hard termination: too many consecutive tool errors.",
4442                        )
4443                        .await;
4444                        return Ok(());
4445                    }
4446
4447                    let _ = tx
4448                        .send(InferenceEvent::ToolCallResult {
4449                            id: call_id.clone(),
4450                            name: tool_name.clone(),
4451                            output: final_output.clone(),
4452                            is_error,
4453                        })
4454                        .await;
4455
4456                    let repeat_guard_exempt = matches!(
4457                        tool_name.as_str(),
4458                        "verify_build" | "git_commit" | "git_push"
4459                    );
4460                    if !repeat_guard_exempt {
4461                        completed_tool_cache.insert(
4462                            canonical_tool_call_key(&tool_name, &res.args),
4463                            CachedToolResult {
4464                                tool_name: tool_name.clone(),
4465                            },
4466                        );
4467                    }
4468
4469                    // Cap output before history
4470                    let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
4471                        self.engine.current_context_length(),
4472                    );
4473                    let capped = if implement_current_plan {
4474                        cap_output(&final_output, 1200)
4475                    } else if compact_ctx
4476                        && (tool_name == "read_file" || tool_name == "inspect_lines")
4477                    {
4478                        // Compact context: cap file reads tightly and add a navigation hint on truncation.
4479                        let limit = 3000usize;
4480                        if final_output.len() > limit {
4481                            let total_lines = final_output.lines().count();
4482                            let mut split_at = limit;
4483                            while !final_output.is_char_boundary(split_at) && split_at > 0 {
4484                                split_at -= 1;
4485                            }
4486                            let scratch = write_output_to_scratch(&final_output, &tool_name)
4487                                .map(|p| format!(" Full file also saved to '{p}'."))
4488                                .unwrap_or_default();
4489                            format!(
4490                                "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
4491                                &final_output[..split_at],
4492                                total_lines,
4493                                total_lines.saturating_sub(150),
4494                                scratch,
4495                            )
4496                        } else {
4497                            final_output.clone()
4498                        }
4499                    } else {
4500                        cap_output_for_tool(&final_output, 8000, &tool_name)
4501                    };
4502                    self.history.push(ChatMessage::tool_result_for_model(
4503                        &call_id,
4504                        &tool_name,
4505                        &capped,
4506                        &self.engine.current_model(),
4507                    ));
4508
4509                    if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
4510                    {
4511                        overview_runtime_trace =
4512                            Some(summarize_runtime_trace_output(&final_output));
4513                    }
4514
4515                    if !architecture_overview_mode
4516                        && !is_error
4517                        && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
4518                            || (toolchain_mode && tool_name == "describe_toolchain"))
4519                    {
4520                        authoritative_tool_output = Some(final_output.clone());
4521                    }
4522
4523                    if !is_error && tool_name == "read_file" {
4524                        if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
4525                            let normalized = normalize_workspace_path(path);
4526                            let read_offset =
4527                                res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
4528                            successful_read_targets.insert(normalized.clone());
4529                            successful_read_regions.insert((normalized.clone(), read_offset));
4530                        }
4531                    }
4532
4533                    if !is_error && tool_name == "grep_files" {
4534                        if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
4535                            let normalized = normalize_workspace_path(path);
4536                            if final_output.starts_with("No matches for ") {
4537                                no_match_grep_targets.insert(normalized);
4538                            } else if grep_output_is_high_fanout(&final_output) {
4539                                broad_grep_targets.insert(normalized);
4540                            } else {
4541                                successful_grep_targets.insert(normalized);
4542                            }
4543                        }
4544                    }
4545
4546                    if is_error
4547                        && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
4548                        && (final_output.contains("search string not found")
4549                            || final_output.contains("search string is too short")
4550                            || final_output.contains("search string matched"))
4551                    {
4552                        if let Some(target) = action_target_path(&tool_name, &res.args) {
4553                            let guidance = if final_output.contains("matched") {
4554                                // Multiple matches — need a more specific anchor. Show the
4555                                // file so the model can pick a unique surrounding context.
4556                                let snippet = read_file_preview_for_retry(&target, 120);
4557                                format!(
4558                                    "EDIT FAILED — search string matched multiple locations in `{target}`. \
4559                                     You need a longer, more unique search string that includes surrounding context.\n\
4560                                     Current file content (first 120 lines):\n```\n{snippet}\n```\n\
4561                                     Retry `{tool_name}` with a search string that is unique in the file."
4562                                )
4563                            } else {
4564                                // Text not found — show the full file so the model can copy
4565                                // the exact current text and retry with correct whitespace.
4566                                let snippet = read_file_preview_for_retry(&target, 200);
4567                                // Also register the file as observed so action_grounding
4568                                // won't block the retry edit.
4569                                let normalized = normalize_workspace_path(&target);
4570                                {
4571                                    let mut ag = self.action_grounding.lock().await;
4572                                    let turn = ag.turn_index;
4573                                    ag.observed_paths.insert(normalized.clone(), turn);
4574                                    ag.inspected_paths.insert(normalized, turn);
4575                                }
4576                                format!(
4577                                    "EDIT FAILED — search string did not match any text in `{target}`.\n\
4578                                     The model must have generated text that differs from what is actually in the file \
4579                                     (wrong whitespace, indentation, or stale content).\n\
4580                                     Current file content (up to 200 lines shown):\n```\n{snippet}\n```\n\
4581                                     Find the exact line(s) to change above, copy the text character-for-character \
4582                                     (preserving indentation), and immediately retry `{tool_name}` \
4583                                     with that exact text as the search string. Do NOT call read_file again — \
4584                                     the content is already shown above."
4585                                )
4586                            };
4587                            loop_intervention = Some(guidance);
4588                            *repeat_count = 0;
4589                        }
4590                    }
4591
4592                    // When guard.rs blocks a shell call with the run_code redirect hint,
4593                    // force the model to recover with run_code instead of giving up.
4594                    if is_error
4595                        && tool_name == "shell"
4596                        && final_output.contains("Use the run_code tool instead")
4597                        && loop_intervention.is_none()
4598                    {
4599                        loop_intervention = Some(
4600                            "STOP. Shell was blocked because this is a computation task. \
4601                             You MUST use `run_code` now — write the code and run it. \
4602                             Do NOT output an error message or give up. \
4603                             Call `run_code` with the appropriate language and code to compute the answer. \
4604                             If writing Python, pass `language: \"python\"`. \
4605                             If writing JavaScript, omit language or pass `language: \"javascript\"`."
4606                                .to_string(),
4607                        );
4608                    }
4609
4610                    // When run_code fails with a Deno parse error, the model likely sent Python
4611                    // code without specifying language: "python". Force a corrective retry.
4612                    if is_error
4613                        && tool_name == "run_code"
4614                        && (final_output.contains("source code could not be parsed")
4615                            || final_output.contains("Expected ';'")
4616                            || final_output.contains("Expected '}'")
4617                            || final_output.contains("is not defined")
4618                                && final_output.contains("deno"))
4619                        && loop_intervention.is_none()
4620                    {
4621                        loop_intervention = Some(
4622                            "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
4623                             code but forgot to pass `language: \"python\"`. \
4624                             Retry run_code with `language: \"python\"` and the same code. \
4625                             Do NOT fall back to shell. Do NOT give up."
4626                                .to_string(),
4627                        );
4628                    }
4629
4630                    if res.blocked_by_policy
4631                        && is_mcp_workspace_read_tool(&tool_name)
4632                        && recoverable_policy_intervention.is_none()
4633                    {
4634                        recoverable_policy_intervention = Some(
4635                            "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
4636                        );
4637                        recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
4638                        recoverable_policy_checkpoint = Some((
4639                            OperatorCheckpointState::BlockedPolicy,
4640                            "MCP workspace read blocked; rerouting to built-in file tools."
4641                                .to_string(),
4642                        ));
4643                    } else if res.blocked_by_policy
4644                        && implement_current_plan
4645                        && is_current_plan_irrelevant_tool(&tool_name)
4646                        && recoverable_policy_intervention.is_none()
4647                    {
4648                        recoverable_policy_intervention = Some(format!(
4649                            "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
4650                            tool_name
4651                        ));
4652                        recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
4653                        recoverable_policy_checkpoint = Some((
4654                            OperatorCheckpointState::BlockedPolicy,
4655                            format!(
4656                                "Current-plan execution blocked unrelated tool `{}`.",
4657                                tool_name
4658                            ),
4659                        ));
4660                    } else if res.blocked_by_policy
4661                        && implement_current_plan
4662                        && final_output.contains("requires recent file evidence")
4663                        && recoverable_policy_intervention.is_none()
4664                    {
4665                        let target = action_target_path(&tool_name, &res.args)
4666                            .unwrap_or_else(|| "the target file".to_string());
4667                        recoverable_policy_intervention = Some(format!(
4668                            "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
4669                        ));
4670                        recoverable_policy_recipe =
4671                            Some(RecoveryScenario::RecentFileEvidenceMissing);
4672                        recoverable_policy_checkpoint = Some((
4673                            OperatorCheckpointState::BlockedRecentFileEvidence,
4674                            format!("Edit blocked on `{target}`; recent file evidence missing."),
4675                        ));
4676                    } else if res.blocked_by_policy
4677                        && implement_current_plan
4678                        && final_output.contains("requires an exact local line window first")
4679                        && recoverable_policy_intervention.is_none()
4680                    {
4681                        let target = action_target_path(&tool_name, &res.args)
4682                            .unwrap_or_else(|| "the target file".to_string());
4683                        recoverable_policy_intervention = Some(format!(
4684                            "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
4685                        ));
4686                        recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
4687                        recoverable_policy_checkpoint = Some((
4688                            OperatorCheckpointState::BlockedExactLineWindow,
4689                            format!("Edit blocked on `{target}`; exact line window required."),
4690                        ));
4691                    } else if res.blocked_by_policy
4692                        && (final_output.contains("Prefer `")
4693                            || final_output.contains("Prefer tool"))
4694                        && recoverable_policy_intervention.is_none()
4695                    {
4696                        recoverable_policy_intervention = Some(final_output.clone());
4697                        recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
4698                        recoverable_policy_checkpoint = Some((
4699                            OperatorCheckpointState::BlockedPolicy,
4700                            "Action blocked by policy; self-correction triggered using tool recommendation."
4701                                .to_string(),
4702                        ));
4703                    } else if res.blocked_by_policy && blocked_policy_output.is_none() {
4704                        blocked_policy_output = Some(final_output.clone());
4705                    }
4706
4707                    if *repeat_count >= 5 {
4708                        let _ = tx.send(InferenceEvent::Done).await;
4709                        return Ok(());
4710                    }
4711
4712                    if implement_current_plan
4713                        && !implementation_started
4714                        && !is_error
4715                        && is_non_mutating_plan_step_tool(&tool_name)
4716                    {
4717                        non_mutating_plan_steps += 1;
4718                    }
4719                }
4720
4721                if sovereign_bootstrap_complete
4722                    && intent.sovereign_mode
4723                    && is_scaffold_request(&effective_user_input)
4724                {
4725                    let response = if let Some(root) = sovereign_task_root.as_deref() {
4726                        format!(
4727                            "Project root created at `{root}`. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
4728                        )
4729                    } else {
4730                        "Project root created. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
4731                            .to_string()
4732                    };
4733                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4734                        .await;
4735                    return Ok(());
4736                }
4737
4738                if let Some(intervention) = recoverable_policy_intervention {
4739                    if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
4740                        self.emit_operator_checkpoint(&tx, state, summary).await;
4741                    }
4742                    if let Some(scenario) = recoverable_policy_recipe.take() {
4743                        let recipe = plan_recovery(scenario, &self.recovery_context);
4744                        self.emit_recovery_recipe_summary(
4745                            &tx,
4746                            recipe.recipe.scenario.label(),
4747                            compact_recovery_plan_summary(&recipe),
4748                        )
4749                        .await;
4750                    }
4751                    loop_intervention = Some(intervention);
4752                    let _ = tx
4753                        .send(InferenceEvent::Thought(
4754                            "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
4755                                .into(),
4756                        ))
4757                        .await;
4758                    continue;
4759                }
4760
4761                if architecture_overview_mode {
4762                    match overview_runtime_trace.as_deref() {
4763                        Some(runtime_trace) => {
4764                            let response = build_architecture_overview_answer(runtime_trace);
4765                            self.history.push(ChatMessage::assistant_text(&response));
4766                            self.transcript.log_agent(&response);
4767
4768                            for chunk in chunk_text(&response, 8) {
4769                                if !chunk.is_empty() {
4770                                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
4771                                }
4772                            }
4773
4774                            let _ = tx.send(InferenceEvent::Done).await;
4775                            break;
4776                        }
4777                        None => {
4778                            loop_intervention = Some(
4779                                "Good. You now have the grounded repository structure. Next, call `trace_runtime_flow` for the runtime/control-flow half of the architecture overview. Prefer topic `user_turn` for the main execution path, or `runtime_subsystems` if that is more direct. Do not call `read_file`, `auto_pin_context`, or LSP tools here."
4780                                    .to_string(),
4781                            );
4782                            continue;
4783                        }
4784                    }
4785                }
4786
4787                if implement_current_plan
4788                    && !implementation_started
4789                    && non_mutating_plan_steps >= non_mutating_plan_hard_cap
4790                {
4791                    let msg = "Current-plan execution stalled: too many non-mutating inspection steps without a concrete edit. Stay on the saved target files, narrow with `inspect_lines`, and then mutate, or ask one specific blocking question instead of continuing broad exploration.".to_string();
4792                    self.history.push(ChatMessage::assistant_text(&msg));
4793                    self.transcript.log_agent(&msg);
4794
4795                    for chunk in chunk_text(&msg, 8) {
4796                        if !chunk.is_empty() {
4797                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
4798                        }
4799                    }
4800
4801                    let _ = tx.send(InferenceEvent::Done).await;
4802                    break;
4803                }
4804
4805                if let Some(blocked_output) = blocked_policy_output {
4806                    self.emit_operator_checkpoint(
4807                        &tx,
4808                        OperatorCheckpointState::BlockedPolicy,
4809                        "A blocked tool path was surfaced directly to the operator.",
4810                    )
4811                    .await;
4812                    self.history
4813                        .push(ChatMessage::assistant_text(&blocked_output));
4814                    self.transcript.log_agent(&blocked_output);
4815
4816                    for chunk in chunk_text(&blocked_output, 8) {
4817                        if !chunk.is_empty() {
4818                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
4819                        }
4820                    }
4821
4822                    let _ = tx.send(InferenceEvent::Done).await;
4823                    break;
4824                }
4825
4826                if let Some(tool_output) = authoritative_tool_output {
4827                    self.history.push(ChatMessage::assistant_text(&tool_output));
4828                    self.transcript.log_agent(&tool_output);
4829
4830                    for chunk in chunk_text(&tool_output, 8) {
4831                        if !chunk.is_empty() {
4832                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
4833                        }
4834                    }
4835
4836                    let _ = tx.send(InferenceEvent::Done).await;
4837                    break;
4838                }
4839
4840                if implement_current_plan && !implementation_started {
4841                    let base = "STOP analyzing. The current plan already defines the task. Use the built-in file evidence you now have and begin implementing the plan in the target files. Do not output preliminary findings or restate contracts.";
4842                    if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
4843                        loop_intervention = Some(format!(
4844                            "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
4845                            base
4846                        ));
4847                    } else {
4848                        loop_intervention = Some(base.to_string());
4849                    }
4850                } else if self.workflow_mode == WorkflowMode::Architect {
4851                    loop_intervention = Some(
4852                        format!(
4853                            "STOP exploring. You have enough evidence for a plan-first answer.\n{}\nUse the tool results already in history. Do not narrate your process. Do not call more tools unless a missing file path makes the handoff impossible.",
4854                            architect_handoff_contract()
4855                        ),
4856                    );
4857                }
4858
4859                // 4. Auto-Verification Loop (The Perfect Bake)
4860                if mutation_occurred && !yolo && !intent.sovereign_mode {
4861                    let _ = tx
4862                        .send(InferenceEvent::Thought(
4863                            "Self-Verification: Running contract-aware workspace verification..."
4864                                .into(),
4865                        ))
4866                        .await;
4867                    let verify_outcome = self.auto_verify_workspace(&turn_mutated_paths).await;
4868                    let verify_res = verify_outcome.summary;
4869                    let verify_ok = verify_outcome.ok;
4870                    self.record_verify_build_result(verify_ok, &verify_res)
4871                        .await;
4872                    self.record_session_verification(
4873                        verify_ok,
4874                        if verify_ok {
4875                            "Automatic workspace verification passed."
4876                        } else {
4877                            "Automatic workspace verification failed."
4878                        },
4879                    );
4880                    self.history.push(ChatMessage::system(&format!(
4881                        "\n# SYSTEM VERIFICATION\n{verify_res}"
4882                    )));
4883                    let _ = tx
4884                        .send(InferenceEvent::Thought(
4885                            "Verification turn injected into history.".into(),
4886                        ))
4887                        .await;
4888                }
4889
4890                // Continue loop – the model will respond to the results.
4891                continue;
4892            } else if let Some(response_text) = text {
4893                if finish_reason.as_deref() == Some("length") && near_context_ceiling {
4894                    if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
4895                        let cleaned = build_session_reset_semantics_answer();
4896                        self.history.push(ChatMessage::assistant_text(&cleaned));
4897                        self.transcript.log_agent(&cleaned);
4898                        for chunk in chunk_text(&cleaned, 8) {
4899                            if !chunk.is_empty() {
4900                                let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
4901                            }
4902                        }
4903                        let _ = tx.send(InferenceEvent::Done).await;
4904                        break;
4905                    }
4906
4907                    let warning = format_runtime_failure(
4908                        RuntimeFailureClass::ContextWindow,
4909                        "Context ceiling reached before the model completed the answer. Hematite trimmed what it could, but this turn still ran out of room. Retry with a narrower inspection step like `grep_files` or `inspect_lines`, or ask for a smaller scoped answer.",
4910                    );
4911                    self.history.push(ChatMessage::assistant_text(&warning));
4912                    self.transcript.log_agent(&warning);
4913                    let _ = tx
4914                        .send(InferenceEvent::Thought(
4915                            "Length recovery: model hit the context ceiling before completing the answer."
4916                                .into(),
4917                        ))
4918                        .await;
4919                    for chunk in chunk_text(&warning, 8) {
4920                        if !chunk.is_empty() {
4921                            let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
4922                        }
4923                    }
4924                    let _ = tx.send(InferenceEvent::Done).await;
4925                    break;
4926                }
4927
4928                if response_text.contains("<|tool_call")
4929                    || response_text.contains("[END_TOOL_REQUEST]")
4930                    || response_text.contains("<|tool_response")
4931                    || response_text.contains("<tool_response|>")
4932                {
4933                    loop_intervention = Some(
4934                        "Your previous response leaked raw native tool transcript markup instead of a valid tool invocation or final answer. Retry immediately. If you need a tool, emit a valid tool call only. If you do not need a tool, answer in plain text with no `<|tool_call>`, `<|tool_response>`, or `[END_TOOL_REQUEST]` markup.".to_string(),
4935                    );
4936                    continue;
4937                }
4938
4939                // 1. Process and route the reasoning block to SPECULAR.
4940                if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
4941                {
4942                    let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
4943                    // Persist for history audit (stripped from next turn by Volatile Reasoning rule).
4944                    // This will be summarized in the next turn's system prompt.
4945                    self.reasoning_history = Some(thought);
4946                }
4947
4948                // 2. Process and stream the final answer to the chat interface.
4949                let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
4950
4951                if implement_current_plan && !implementation_started {
4952                    loop_intervention = Some(
4953                        "Do not stop at analysis. Implement the current saved plan now using built-in workspace tools and the target files already named in the plan. Only answer without edits if you have a concrete blocking question.".to_string(),
4954                    );
4955                    continue;
4956                }
4957
4958                // [Hardened Interface] Strictly respect the stripper.
4959                // If it's empty after stripping think blocks, the model thought through its
4960                // answer but forgot to emit it (common with Qwen3 models in architect/ask mode).
4961                // Nudge it rather than silently dropping the turn — but cap at 2 retries so a
4962                // model that keeps returning whitespace/empty doesn't spin all 25 iterations.
4963                if cleaned.is_empty() {
4964                    empty_cleaned_nudges += 1;
4965                    if empty_cleaned_nudges == 1 {
4966                        loop_intervention = Some(
4967                            "Your visible response was empty. The tool already returned data. \
4968                             Write your answer now in plain text — no <think> tags, no tool calls. \
4969                             State the key facts in 2-5 sentences and stop."
4970                                .to_string(),
4971                        );
4972                        continue;
4973                    } else if empty_cleaned_nudges == 2 {
4974                        loop_intervention = Some(
4975                            "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
4976                             Write the answer in plain text right now. \
4977                             Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
4978                                .to_string(),
4979                        );
4980                        continue;
4981                    }
4982                    // Nudge budget exhausted — surface as a recoverable empty-response failure
4983                    // so the TUI unblocks instead of hanging for the full max_iters budget.
4984                    let class = RuntimeFailureClass::EmptyModelResponse;
4985                    self.emit_runtime_failure(
4986                        &tx,
4987                        class,
4988                        "Model returned empty content after 2 nudge attempts.",
4989                    )
4990                    .await;
4991                    break;
4992                }
4993
4994                let architect_handoff = self.persist_architect_handoff(&cleaned);
4995                self.history.push(ChatMessage::assistant_text(&cleaned));
4996                self.transcript.log_agent(&cleaned);
4997                visible_closeout_emitted = true;
4998
4999                // Send in smooth chunks for that professional UI feel.
5000                for chunk in chunk_text(&cleaned, 8) {
5001                    if !chunk.is_empty() {
5002                        let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
5003                    }
5004                }
5005
5006                if let Some(plan) = architect_handoff.as_ref() {
5007                    let note = architect_handoff_operator_note(plan);
5008                    self.history.push(ChatMessage::system(&note));
5009                    self.transcript.log_system(&note);
5010                    let _ = tx
5011                        .send(InferenceEvent::MutedToken(format!("\n{}", note)))
5012                        .await;
5013                }
5014
5015                self.emit_done_events(&tx).await;
5016                break;
5017            } else {
5018                let detail = "Model returned an empty response.";
5019                let class = classify_runtime_failure(detail);
5020                if should_retry_runtime_failure(class) {
5021                    if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
5022                        if let RecoveryDecision::Attempt(plan) =
5023                            attempt_recovery(scenario, &mut self.recovery_context)
5024                        {
5025                            self.transcript.log_system(
5026                                "Automatic provider recovery triggered: model returned an empty response.",
5027                            );
5028                            self.emit_recovery_recipe_summary(
5029                                &tx,
5030                                plan.recipe.scenario.label(),
5031                                compact_recovery_plan_summary(&plan),
5032                            )
5033                            .await;
5034                            let _ = tx
5035                                .send(InferenceEvent::ProviderStatus {
5036                                    state: ProviderRuntimeState::Recovering,
5037                                    summary: compact_runtime_recovery_summary(class).into(),
5038                                })
5039                                .await;
5040                            self.emit_operator_checkpoint(
5041                                &tx,
5042                                OperatorCheckpointState::RecoveringProvider,
5043                                compact_runtime_recovery_summary(class),
5044                            )
5045                            .await;
5046                            continue;
5047                        }
5048                    }
5049                }
5050
5051                self.emit_runtime_failure(&tx, class, detail).await;
5052                break;
5053            }
5054        }
5055
5056        let task_progress_after = if implement_current_plan {
5057            read_task_checklist_progress()
5058        } else {
5059            None
5060        };
5061
5062        if implement_current_plan
5063            && !visible_closeout_emitted
5064            && should_continue_plan_execution(
5065                current_plan_pass,
5066                task_progress_before,
5067                task_progress_after,
5068                &turn_mutated_paths,
5069            )
5070        {
5071            if let Some(progress) = task_progress_after {
5072                let _ = tx
5073                    .send(InferenceEvent::Thought(format!(
5074                        "Checklist still has {} unchecked item(s). Continuing autonomous implementation pass {}.",
5075                        progress.remaining,
5076                        current_plan_pass + 1
5077                    )))
5078                    .await;
5079                let synthetic_turn = UserTurn {
5080                    text: build_continue_plan_execution_prompt(progress),
5081                    attached_document: None,
5082                    attached_image: None,
5083                };
5084                return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
5085            }
5086        }
5087
5088        if implement_current_plan && !visible_closeout_emitted {
5089            // FORCE a summary turn if we had no natural closeout (e.g. hit a mandate or finished all tool budget).
5090            let _ = tx.send(InferenceEvent::Thought("Implementation passthrough complete. Requesting final engineering summary (NLG-only mode)...".to_string())).await;
5091
5092            let outstanding_note = task_progress_after
5093                .filter(|progress| progress.has_open_items())
5094                .map(|progress| {
5095                    format!(
5096                        " `.hematite/TASK.md` still has {} unchecked item(s); explain the concrete blocker or remaining non-optional work.",
5097                        progress.remaining
5098                    )
5099                })
5100                .unwrap_or_default();
5101            let synthetic_turn = UserTurn {
5102                text: format!(
5103                    "Implementation passes complete. YOU ARE NOW IN SUMMARY MODE. STOP calling tools — all tools are hidden. Provide a concise human engineering summary of what you built, what was verified, and whether `.hematite/TASK.md` is fully checked off.{}",
5104                    outstanding_note
5105                ),
5106                attached_document: None,
5107                attached_image: None,
5108            };
5109            // Note: We use recursion to force one last NLG pass.
5110            // We set yolo=true to suppress tools.
5111            return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), true)).await;
5112        }
5113
5114        if plan_drafted_this_turn
5115            && matches!(
5116                self.workflow_mode,
5117                WorkflowMode::Auto | WorkflowMode::Architect
5118            )
5119        {
5120            let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
5121            let _ = tx
5122                .send(InferenceEvent::ApprovalRequired {
5123                    id: "plan_approval".to_string(),
5124                    name: "plan_authorization".to_string(),
5125                    display: "A comprehensive scaffolding blueprint has been drafted to .hematite/PLAN.md. Autonomously execute implementation now?".to_string(),
5126                    diff: None,
5127                    mutation_label: Some("SYSTEM PLAN AUTHORIZATION".to_string()),
5128                    responder: appr_tx,
5129                })
5130                .await;
5131
5132            if let Ok(true) = appr_rx.await {
5133                // Wipe conversation history to prevent hallucination cycles on 9B models.
5134                // The recursive run_turn call will rebuild the system prompt from scratch
5135                // and inject the PLAN.md blueprint via the implement-plan pathway.
5136                self.history.clear();
5137                self.running_summary = None;
5138                self.set_workflow_mode(WorkflowMode::Code);
5139
5140                let _ = tx.send(InferenceEvent::ChainImplementPlan).await;
5141
5142                let next_input = implement_current_plan_prompt().to_string();
5143                let synthetic_turn = UserTurn {
5144                    text: next_input,
5145                    attached_document: None,
5146                    attached_image: None,
5147                };
5148                return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
5149            }
5150        }
5151
5152        self.trim_history(80);
5153        self.refresh_session_memory();
5154        // Record the goal and increment the turn counter before persisting.
5155        self.last_goal = Some(user_input.chars().take(300).collect());
5156        self.turn_count = self.turn_count.saturating_add(1);
5157        self.save_session();
5158        self.emit_compaction_pressure(&tx).await;
5159        Ok(())
5160    }
5161
5162    async fn emit_runtime_failure(
5163        &mut self,
5164        tx: &mpsc::Sender<InferenceEvent>,
5165        class: RuntimeFailureClass,
5166        detail: &str,
5167    ) {
5168        if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
5169            let decision = preview_recovery_decision(scenario, &self.recovery_context);
5170            self.emit_recovery_recipe_summary(
5171                tx,
5172                scenario.label(),
5173                compact_recovery_decision_summary(&decision),
5174            )
5175            .await;
5176            let needs_refresh = match &decision {
5177                RecoveryDecision::Attempt(plan) => plan
5178                    .recipe
5179                    .steps
5180                    .contains(&RecoveryStep::RefreshRuntimeProfile),
5181                RecoveryDecision::Escalate { recipe, .. } => {
5182                    recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
5183                }
5184            };
5185            if needs_refresh {
5186                if let Some((model_id, context_length, changed)) = self
5187                    .refresh_runtime_profile_and_report(tx, "context_window_failure")
5188                    .await
5189                {
5190                    let note = if changed {
5191                        format!(
5192                            "Runtime refresh after context-window failure: model {} | CTX {}",
5193                            model_id, context_length
5194                        )
5195                    } else {
5196                        format!(
5197                            "Runtime refresh after context-window failure confirms model {} | CTX {}",
5198                            model_id, context_length
5199                        )
5200                    };
5201                    let _ = tx.send(InferenceEvent::Thought(note)).await;
5202                }
5203            }
5204        }
5205        if let Some(state) = provider_state_for_runtime_failure(class) {
5206            let _ = tx
5207                .send(InferenceEvent::ProviderStatus {
5208                    state,
5209                    summary: compact_runtime_failure_summary(class).into(),
5210                })
5211                .await;
5212        }
5213        if let Some(state) = checkpoint_state_for_runtime_failure(class) {
5214            self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
5215                .await;
5216        }
5217        let formatted = format_runtime_failure(class, detail);
5218        self.history.push(ChatMessage::system(&format!(
5219            "# RUNTIME FAILURE\n{}",
5220            formatted
5221        )));
5222        self.transcript.log_system(&formatted);
5223        let _ = tx.send(InferenceEvent::Error(formatted)).await;
5224        let _ = tx.send(InferenceEvent::Done).await;
5225    }
5226
5227    /// Contract-aware self verification. Build is still the base proof, but stack-specific
5228    /// runtime contracts can add stronger checks such as website route and asset validation.
5229    async fn auto_verify_workspace(
5230        &self,
5231        mutated_paths: &std::collections::BTreeSet<String>,
5232    ) -> AutoVerificationOutcome {
5233        let root = crate::tools::file_ops::workspace_root();
5234        let profile = crate::agent::workspace_profile::load_workspace_profile(&root)
5235            .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(&root));
5236
5237        let mut sections = Vec::new();
5238        let mut overall_ok = true;
5239        let contract = profile.runtime_contract.as_ref();
5240        let verification_workflows: Vec<String> = match contract {
5241            Some(contract) if !contract.verification_workflows.is_empty() => {
5242                contract.verification_workflows.clone()
5243            }
5244            _ if profile.build_hint.is_some() || profile.verify_profile.is_some() => {
5245                vec!["build".to_string()]
5246            }
5247            _ => Vec::new(),
5248        };
5249
5250        for workflow in verification_workflows {
5251            if !should_run_contract_verification_workflow(contract, &workflow, mutated_paths) {
5252                continue;
5253            }
5254            let outcome = self.auto_run_verification_workflow(&workflow).await;
5255            overall_ok &= outcome.ok;
5256            sections.push(outcome.summary);
5257        }
5258
5259        if sections.is_empty() {
5260            sections.push(
5261                "[verify]\nVERIFICATION SKIPPED: Workspace profile does not define an automatic verification workflow for this stack."
5262                    .to_string(),
5263            );
5264        }
5265
5266        let header = if overall_ok {
5267            "WORKSPACE VERIFICATION SUCCESS: Automatic validation passed."
5268        } else {
5269            "WORKSPACE VERIFICATION FAILURE: Automatic validation found problems."
5270        };
5271
5272        AutoVerificationOutcome {
5273            ok: overall_ok,
5274            summary: format!("{}\n\n{}", header, sections.join("\n\n")),
5275        }
5276    }
5277
5278    async fn auto_run_verification_workflow(&self, workflow: &str) -> AutoVerificationOutcome {
5279        match workflow {
5280            "build" | "test" | "lint" | "fix" => {
5281                match crate::tools::verify_build::execute(
5282                    &serde_json::json!({ "action": workflow }),
5283                )
5284                .await
5285                {
5286                    Ok(out) => AutoVerificationOutcome {
5287                        ok: true,
5288                        summary: format!(
5289                            "[{}]\n{} SUCCESS: Automatic {} verification passed.\n\n{}",
5290                            workflow,
5291                            workflow.to_ascii_uppercase(),
5292                            workflow,
5293                            cap_output(&out, 2000)
5294                        ),
5295                    },
5296                    Err(e) => AutoVerificationOutcome {
5297                        ok: false,
5298                        summary: format!(
5299                            "[{}]\n{} FAILURE: Automatic {} verification failed.\n\n{}",
5300                            workflow,
5301                            workflow.to_ascii_uppercase(),
5302                            workflow,
5303                            cap_output(&e, 2000)
5304                        ),
5305                    },
5306                }
5307            }
5308            other => {
5309                // DISPATCH Generic workflows (e.g. website_validate, server_probe, etc.)
5310                let args = serde_json::json!({ "workflow": other });
5311                match crate::tools::workspace_workflow::run_workspace_workflow(&args).await {
5312                    Ok(out) => {
5313                        // Specialized workflows rely on "Result: PASS" or "Result: FAIL" markers.
5314                        // Standard shell fallbacks return OK if exit code was 0.
5315                        let ok = !out.contains("Result: FAIL") && !out.contains("Error:");
5316                        AutoVerificationOutcome {
5317                            ok,
5318                            summary: format!("[{}]\n{}", other, out.trim()),
5319                        }
5320                    }
5321                    Err(e) => {
5322                        // If a specialized workflow needs "Auto-Booting" (e.g. website),
5323                        // we can handle a retry here or delegate the intelligence to the tool itself.
5324                        // For website_validate, we attempt a boot if it looks like a connection failure.
5325                        let needs_boot = e.contains("No tracked website server labeled")
5326                            || e.contains("HTTP probe failed")
5327                            || e.contains("Connection refused")
5328                            || e.contains("error trying to connect");
5329
5330                        if other == "website_validate" && needs_boot {
5331                            let start_args = serde_json::json!({ "workflow": "website_start" });
5332                            if let Ok(_) = crate::tools::workspace_workflow::run_workspace_workflow(
5333                                &start_args,
5334                            )
5335                            .await
5336                            {
5337                                if let Ok(retry_out) =
5338                                    crate::tools::workspace_workflow::run_workspace_workflow(&args)
5339                                        .await
5340                                {
5341                                    let ok = !retry_out.contains("Result: FAIL")
5342                                        && !retry_out.contains("Error:");
5343                                    return AutoVerificationOutcome {
5344                                        ok,
5345                                        summary: format!(
5346                                            "[{}]\n(Auto-booted) {}",
5347                                            other,
5348                                            retry_out.trim()
5349                                        ),
5350                                    };
5351                                }
5352                            }
5353                        }
5354
5355                        AutoVerificationOutcome {
5356                            ok: false,
5357                            summary: format!("[{}]\nVERIFICATION FAILURE: {}", other, e),
5358                        }
5359                    }
5360                }
5361            }
5362        }
5363    }
5364
5365    /// Triggers an LLM call to summarize old messages if history exceeds the VRAM character limit.
5366    /// Triggers the Deterministic Smart Compaction algorithm to shrink history while preserving context.
5367    /// Triggers the Recursive Context Compactor.
5368    async fn compact_history_if_needed(
5369        &mut self,
5370        tx: &mpsc::Sender<InferenceEvent>,
5371        anchor_index: Option<usize>,
5372    ) -> Result<bool, String> {
5373        let vram_ratio = self.gpu_state.ratio();
5374        let context_length = self.engine.current_context_length();
5375        let config = CompactionConfig::adaptive(context_length, vram_ratio);
5376
5377        if !compaction::should_compact(&self.history, context_length, vram_ratio) {
5378            return Ok(false);
5379        }
5380
5381        let _ = tx
5382            .send(InferenceEvent::Thought(format!(
5383                "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
5384                context_length / 1000,
5385                vram_ratio * 100.0,
5386                config.max_estimated_tokens / 1000,
5387            )))
5388            .await;
5389
5390        let result = compaction::compact_history(
5391            &self.history,
5392            self.running_summary.as_deref(),
5393            config,
5394            anchor_index,
5395        );
5396
5397        let removed_message_count = self.history.len().saturating_sub(result.messages.len());
5398        self.history = result.messages;
5399        self.running_summary = result.summary;
5400
5401        // Layer 6: Memory Synthesis (Task Context Persistence)
5402        let previous_memory = self.session_memory.clone();
5403        self.session_memory = compaction::extract_memory(&self.history);
5404        self.session_memory
5405            .inherit_runtime_ledger_from(&previous_memory);
5406        self.session_memory.record_compaction(
5407            removed_message_count,
5408            format!(
5409                "Compacted history around active task '{}' and preserved {} working-set file(s).",
5410                self.session_memory.current_task,
5411                self.session_memory.working_set.len()
5412            ),
5413        );
5414        self.emit_compaction_pressure(tx).await;
5415
5416        // Jinja alignment: preserved slice may start with assistant/tool messages.
5417        // Strip any leading non-user messages so the first non-system message is always user.
5418        let first_non_sys = self
5419            .history
5420            .iter()
5421            .position(|m| m.role != "system")
5422            .unwrap_or(self.history.len());
5423        if first_non_sys < self.history.len() {
5424            if let Some(user_offset) = self.history[first_non_sys..]
5425                .iter()
5426                .position(|m| m.role == "user")
5427            {
5428                if user_offset > 0 {
5429                    self.history
5430                        .drain(first_non_sys..first_non_sys + user_offset);
5431                }
5432            }
5433        }
5434
5435        let _ = tx
5436            .send(InferenceEvent::Thought(format!(
5437                "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
5438                self.session_memory.current_task,
5439                self.session_memory.working_set.len()
5440            )))
5441            .await;
5442        let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
5443        self.emit_recovery_recipe_summary(
5444            tx,
5445            recipe.recipe.scenario.label(),
5446            compact_recovery_plan_summary(&recipe),
5447        )
5448        .await;
5449        self.emit_operator_checkpoint(
5450            tx,
5451            OperatorCheckpointState::HistoryCompacted,
5452            format!(
5453                "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
5454                self.session_memory.current_task,
5455                self.session_memory.working_set.len()
5456            ),
5457        )
5458        .await;
5459
5460        Ok(true)
5461    }
5462
5463    /// Query The Vein for context relevant to the user's message.
5464    /// Runs hybrid BM25 + semantic search (semantic requires embedding model in LM Studio).
5465    /// Returns a formatted system message string, or None if nothing useful found.
5466    fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
5467        // Skip trivial / very short inputs.
5468        if query.trim().split_whitespace().count() < 3 {
5469            return None;
5470        }
5471
5472        let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
5473        if results.is_empty() {
5474            return None;
5475        }
5476
5477        let semantic_active = self.vein.has_any_embeddings();
5478        let header = if semantic_active {
5479            "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
5480             Use this to answer without needing extra read_file calls where possible.\n\n"
5481        } else {
5482            "# Relevant context from The Vein (BM25 keyword retrieval)\n\
5483             Use this to answer without needing extra read_file calls where possible.\n\n"
5484        };
5485
5486        let mut ctx = String::from(header);
5487        let mut paths: Vec<String> = Vec::new();
5488
5489        let mut total = 0usize;
5490        const MAX_CTX_CHARS: usize = 1_500;
5491
5492        for r in results {
5493            if total >= MAX_CTX_CHARS {
5494                break;
5495            }
5496            let snippet = if r.content.len() > 500 {
5497                format!("{}...", &r.content[..500])
5498            } else {
5499                r.content.clone()
5500            };
5501            ctx.push_str(&format!("--- {} ---\n{}\n\n", r.path, snippet));
5502            total += snippet.len() + r.path.len() + 10;
5503            if !paths.contains(&r.path) {
5504                paths.push(r.path);
5505            }
5506        }
5507
5508        Some((ctx, paths))
5509    }
5510
5511    /// Returns the conversation history (WITHOUT the system prompt) for the context window.
5512    /// This ensures we don't have redundant system blocks and prevents Jinja crashes.
5513    fn context_window_slice(&self) -> Vec<ChatMessage> {
5514        let mut result = Vec::new();
5515
5516        // Skip index 0 (the raw system message) and any stray system messages in history.
5517        if self.history.len() > 1 {
5518            for m in &self.history[1..] {
5519                if m.role == "system" {
5520                    continue;
5521                }
5522
5523                let mut sanitized = m.clone();
5524                // DEEP SANITIZE: LM Studio Jinja templates for Qwen crash on truly empty content.
5525                if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
5526                    sanitized.content = MessageContent::Text(" ".into());
5527                }
5528                result.push(sanitized);
5529            }
5530        }
5531
5532        // Jinja Guard: The first message after the system prompt MUST be 'user'.
5533        // If not (e.g. because of compaction), we insert a tiny anchor.
5534        if !result.is_empty() && result[0].role != "user" {
5535            result.insert(0, ChatMessage::user("Continuing previous context..."));
5536        }
5537
5538        result
5539    }
5540
5541    fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
5542        let mut result = Vec::new();
5543
5544        if self.history.len() > 1 {
5545            let start = start_idx.max(1).min(self.history.len());
5546            for m in &self.history[start..] {
5547                if m.role == "system" {
5548                    continue;
5549                }
5550
5551                let mut sanitized = m.clone();
5552                if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
5553                    sanitized.content = MessageContent::Text(" ".into());
5554                }
5555                result.push(sanitized);
5556            }
5557        }
5558
5559        if !result.is_empty() && result[0].role != "user" {
5560            result.insert(0, ChatMessage::user("Continuing current plan execution..."));
5561        }
5562
5563        result
5564    }
5565
5566    /// Drop old turns from the middle of history.
5567    fn trim_history(&mut self, max_messages: usize) {
5568        if self.history.len() <= max_messages {
5569            return;
5570        }
5571        // Always keep [0] (system prompt).
5572        let excess = self.history.len() - max_messages;
5573        self.history.drain(1..=excess);
5574    }
5575
5576    /// P1: Attempt to fix malformed JSON tool arguments by asking the model to re-output them.
5577    async fn repair_tool_args(
5578        &self,
5579        tool_name: &str,
5580        bad_json: &str,
5581        tx: &mpsc::Sender<InferenceEvent>,
5582    ) -> Result<Value, String> {
5583        let _ = tx
5584            .send(InferenceEvent::Thought(format!(
5585                "Attempting to repair malformed JSON for '{}'...",
5586                tool_name
5587            )))
5588            .await;
5589
5590        let prompt = format!(
5591            "The following JSON for tool '{}' is malformed and failed to parse:\n\n```json\n{}\n```\n\nOutput ONLY the corrected JSON string that fixes the syntax error (e.g. missing commas, unescaped quotes). Do NOT include markdown blocks or any other text.",
5592            tool_name, bad_json
5593        );
5594
5595        let messages = vec![
5596            ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
5597            ChatMessage::user(&prompt),
5598        ];
5599
5600        // Use fast model for speed if available.
5601        let (text, _, _, _) = self
5602            .engine
5603            .call_with_tools(&messages, &[], self.fast_model.as_deref())
5604            .await
5605            .map_err(|e| e.to_string())?;
5606
5607        let cleaned = text
5608            .unwrap_or_default()
5609            .trim()
5610            .trim_start_matches("```json")
5611            .trim_start_matches("```")
5612            .trim_end_matches("```")
5613            .trim()
5614            .to_string();
5615
5616        serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
5617    }
5618
5619    /// P2: Run a fast validation step after file writes to check for subtle logic errors.
5620    async fn run_critic_check(
5621        &self,
5622        path: &str,
5623        content: &str,
5624        tx: &mpsc::Sender<InferenceEvent>,
5625    ) -> Option<String> {
5626        // Only run for source code files.
5627        let ext = std::path::Path::new(path)
5628            .extension()
5629            .and_then(|e| e.to_str())
5630            .unwrap_or("");
5631        const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
5632        if !CRITIC_EXTS.contains(&ext) {
5633            return None;
5634        }
5635
5636        let _ = tx
5637            .send(InferenceEvent::Thought(format!(
5638                "CRITIC: Reviewing changes to '{}'...",
5639                path
5640            )))
5641            .await;
5642
5643        let truncated = cap_output(content, 4000);
5644
5645        const WEB_EXTS_CRITIC: &[&str] = &[
5646            "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
5647        ];
5648        let is_web_file = WEB_EXTS_CRITIC.contains(&ext);
5649
5650        let prompt = if is_web_file {
5651            format!(
5652                "You are a senior web developer doing a quality review of '{}'. \
5653                Identify ONLY real problems — missing, broken, or incomplete things that would \
5654                make this file not work or look bad in production. Check:\n\
5655                - HTML: missing DOCTYPE/charset/title/viewport meta, broken links, missing aria, unsemantic structure\n\
5656                - CSS: hardcoded px instead of responsive units, missing mobile media queries, class names used in HTML but not defined here\n\
5657                - JS/TS: missing error handling, undefined variables, console.log left in, DOM elements referenced that may not exist\n\
5658                - All: placeholder text/colors/lorem-ipsum left in, TODO comments, empty sections\n\
5659                Be extremely concise. List issues as short bullets. If everything is production-ready, output 'PASS'.\n\n\
5660                ```{}\n{}\n```",
5661                path, ext, truncated
5662            )
5663        } else {
5664            format!(
5665                "You are a Senior Security and Code Quality auditor. Review this file content for '{}' \
5666                and identify any critical logic errors, security vulnerabilities, or missing error handling. \
5667                Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
5668                path, ext, truncated
5669            )
5670        };
5671
5672        let messages = vec![
5673            ChatMessage::system("You are a technical critic. Identify ONLY real issues that need fixing. Output 'PASS' if none found."),
5674            ChatMessage::user(&prompt)
5675        ];
5676
5677        let (text, _, _, _) = self
5678            .engine
5679            .call_with_tools(&messages, &[], self.fast_model.as_deref())
5680            .await
5681            .ok()?;
5682
5683        let critique = text?.trim().to_string();
5684        if critique.to_uppercase().contains("PASS") || critique.is_empty() {
5685            None
5686        } else {
5687            Some(critique)
5688        }
5689    }
5690}
5691
5692// ── Tool dispatcher ───────────────────────────────────────────────────────────
5693
5694pub async fn dispatch_tool(
5695    name: &str,
5696    args: &Value,
5697    config: &crate::agent::config::HematiteConfig,
5698) -> Result<String, String> {
5699    dispatch_builtin_tool(name, args, config).await
5700}
5701
5702fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
5703    let trimmed = text.trim();
5704    let stripped = trimmed
5705        .strip_prefix("/think")
5706        .or_else(|| trimmed.strip_prefix("/no_think"))
5707        .map(str::trim)
5708        .unwrap_or(trimmed)
5709        .trim_start_matches('\n')
5710        .trim();
5711    (!stripped.is_empty()).then(|| stripped.to_string())
5712}
5713
5714fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
5715    if tool_name != "inspect_host" {
5716        return;
5717    }
5718
5719    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5720        return;
5721    };
5722    if topic != "fix_plan" {
5723        return;
5724    }
5725
5726    let issue_missing = args
5727        .get("issue")
5728        .and_then(|v| v.as_str())
5729        .map(str::trim)
5730        .is_none_or(|value| value.is_empty());
5731    if !issue_missing {
5732        return;
5733    }
5734
5735    let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
5736        return;
5737    };
5738
5739    let Value::Object(map) = args else {
5740        return;
5741    };
5742    map.insert(
5743        "issue".to_string(),
5744        Value::String(fallback_issue.to_string()),
5745    );
5746}
5747
5748fn fill_missing_dns_lookup_name(
5749    tool_name: &str,
5750    args: &mut Value,
5751    latest_user_prompt: Option<&str>,
5752) {
5753    if tool_name != "inspect_host" {
5754        return;
5755    }
5756
5757    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5758        return;
5759    };
5760    if topic != "dns_lookup" {
5761        return;
5762    }
5763
5764    let name_missing = args
5765        .get("name")
5766        .and_then(|v| v.as_str())
5767        .map(str::trim)
5768        .is_none_or(|value| value.is_empty());
5769    if !name_missing {
5770        return;
5771    }
5772
5773    let Some(prompt) = latest_user_prompt else {
5774        return;
5775    };
5776    let Some(name) = extract_dns_lookup_target_from_text(prompt) else {
5777        return;
5778    };
5779
5780    let Value::Object(map) = args else {
5781        return;
5782    };
5783    map.insert("name".to_string(), Value::String(name));
5784}
5785
5786fn fill_missing_dns_lookup_type(
5787    tool_name: &str,
5788    args: &mut Value,
5789    latest_user_prompt: Option<&str>,
5790) {
5791    if tool_name != "inspect_host" {
5792        return;
5793    }
5794
5795    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5796        return;
5797    };
5798    if topic != "dns_lookup" {
5799        return;
5800    }
5801
5802    let type_missing = args
5803        .get("type")
5804        .and_then(|v| v.as_str())
5805        .map(str::trim)
5806        .is_none_or(|value| value.is_empty());
5807    if !type_missing {
5808        return;
5809    }
5810
5811    let record_type = latest_user_prompt
5812        .and_then(extract_dns_record_type_from_text)
5813        .unwrap_or("A");
5814
5815    let Value::Object(map) = args else {
5816        return;
5817    };
5818    map.insert("type".to_string(), Value::String(record_type.to_string()));
5819}
5820
5821fn fill_missing_event_query_args(
5822    tool_name: &str,
5823    args: &mut Value,
5824    latest_user_prompt: Option<&str>,
5825) {
5826    if tool_name != "inspect_host" {
5827        return;
5828    }
5829
5830    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5831        return;
5832    };
5833    if topic != "event_query" {
5834        return;
5835    }
5836
5837    let Some(prompt) = latest_user_prompt else {
5838        return;
5839    };
5840
5841    let Value::Object(map) = args else {
5842        return;
5843    };
5844
5845    let event_id_missing = map.get("event_id").and_then(|v| v.as_u64()).is_none();
5846    if event_id_missing {
5847        if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
5848            map.insert(
5849                "event_id".to_string(),
5850                Value::Number(serde_json::Number::from(event_id)),
5851            );
5852        }
5853    }
5854
5855    let log_missing = map
5856        .get("log")
5857        .and_then(|v| v.as_str())
5858        .map(str::trim)
5859        .is_none_or(|value| value.is_empty());
5860    if log_missing {
5861        if let Some(log_name) = extract_event_query_log_from_text(prompt) {
5862            map.insert("log".to_string(), Value::String(log_name.to_string()));
5863        }
5864    }
5865
5866    let level_missing = map
5867        .get("level")
5868        .and_then(|v| v.as_str())
5869        .map(str::trim)
5870        .is_none_or(|value| value.is_empty());
5871    if level_missing {
5872        if let Some(level) = extract_event_query_level_from_text(prompt) {
5873            map.insert("level".to_string(), Value::String(level.to_string()));
5874        }
5875    }
5876
5877    let hours_missing = map.get("hours").and_then(|v| v.as_u64()).is_none();
5878    if hours_missing {
5879        if let Some(hours) = extract_event_query_hours_from_text(prompt) {
5880            map.insert(
5881                "hours".to_string(),
5882                Value::Number(serde_json::Number::from(hours)),
5883            );
5884        }
5885    }
5886}
5887
5888fn should_rewrite_shell_to_fix_plan(
5889    tool_name: &str,
5890    args: &Value,
5891    latest_user_prompt: Option<&str>,
5892) -> bool {
5893    if tool_name != "shell" {
5894        return false;
5895    }
5896    let Some(prompt) = latest_user_prompt else {
5897        return false;
5898    };
5899    if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
5900        return false;
5901    }
5902    let command = args
5903        .get("command")
5904        .and_then(|value| value.as_str())
5905        .unwrap_or("");
5906    shell_looks_like_structured_host_inspection(command)
5907}
5908
5909fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
5910    let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(flag));
5911    let regex = regex::Regex::new(&pattern).ok()?;
5912    let captures = regex.captures(command)?;
5913    captures.get(1).map(|m| m.as_str().to_string())
5914}
5915
5916fn clean_shell_dns_token(token: &str) -> String {
5917    token
5918        .trim_matches(|c: char| {
5919            c.is_whitespace()
5920                || matches!(
5921                    c,
5922                    '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ';' | ',' | '`'
5923                )
5924        })
5925        .trim_end_matches(|c: char| matches!(c, ':' | '.'))
5926        .to_string()
5927}
5928
5929fn looks_like_dns_target(token: &str) -> bool {
5930    let cleaned = clean_shell_dns_token(token);
5931    if cleaned.is_empty() {
5932        return false;
5933    }
5934
5935    let lower = cleaned.to_ascii_lowercase();
5936    if matches!(
5937        lower.as_str(),
5938        "a" | "aaaa"
5939            | "mx"
5940            | "srv"
5941            | "txt"
5942            | "cname"
5943            | "ptr"
5944            | "soa"
5945            | "any"
5946            | "resolve-dnsname"
5947            | "nslookup"
5948            | "host"
5949            | "dig"
5950            | "powershell"
5951            | "-command"
5952            | "foreach-object"
5953            | "select-object"
5954            | "address"
5955            | "ipaddress"
5956            | "name"
5957            | "type"
5958    ) {
5959        return false;
5960    }
5961
5962    if lower == "localhost" || cleaned.parse::<std::net::IpAddr>().is_ok() {
5963        return true;
5964    }
5965
5966    cleaned.contains('.')
5967        && cleaned
5968            .chars()
5969            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '*'))
5970}
5971
5972fn extract_dns_lookup_target_from_shell(command: &str) -> Option<String> {
5973    for pattern in [
5974        r#"(?i)-name\s+['"]?([^'"\s;()]+)['"]?"#,
5975        r#"(?i)(?:gethostaddresses|gethostentry)\s*\(\s*['"]([^'"]+)['"]\s*\)"#,
5976        r#"(?i)\b(?:resolve-dnsname|nslookup|host|dig)\s+['"]?([^'"\s;()]+)['"]?"#,
5977    ] {
5978        let regex = regex::Regex::new(pattern).ok()?;
5979        if let Some(value) = regex
5980            .captures(command)
5981            .and_then(|captures| captures.get(1).map(|m| clean_shell_dns_token(m.as_str())))
5982            .filter(|value| looks_like_dns_target(value))
5983        {
5984            return Some(value);
5985        }
5986    }
5987
5988    let quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok()?;
5989    for captures in quoted.captures_iter(command) {
5990        let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
5991        if looks_like_dns_target(&candidate) {
5992            return Some(candidate);
5993        }
5994    }
5995
5996    command
5997        .split_whitespace()
5998        .map(clean_shell_dns_token)
5999        .find(|token| looks_like_dns_target(token))
6000}
6001
6002fn extract_dns_lookup_target_from_text(text: &str) -> Option<String> {
6003    let quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok()?;
6004    for captures in quoted.captures_iter(text) {
6005        let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
6006        if looks_like_dns_target(&candidate) {
6007            return Some(candidate);
6008        }
6009    }
6010
6011    text.split_whitespace()
6012        .map(clean_shell_dns_token)
6013        .find(|token| looks_like_dns_target(token))
6014}
6015
6016fn extract_dns_record_type_from_text(text: &str) -> Option<&'static str> {
6017    let lower = text.to_ascii_lowercase();
6018    if lower.contains("aaaa record") || lower.contains("ipv6 address") {
6019        Some("AAAA")
6020    } else if lower.contains("mx record") {
6021        Some("MX")
6022    } else if lower.contains("srv record") {
6023        Some("SRV")
6024    } else if lower.contains("txt record") {
6025        Some("TXT")
6026    } else if lower.contains("cname record") {
6027        Some("CNAME")
6028    } else if lower.contains("soa record") {
6029        Some("SOA")
6030    } else if lower.contains("ptr record") {
6031        Some("PTR")
6032    } else if lower.contains("a record")
6033        || (lower.contains("ip address") && lower.contains(" of "))
6034        || (lower.contains("what") && lower.contains("ip") && lower.contains("for"))
6035    {
6036        Some("A")
6037    } else {
6038        None
6039    }
6040}
6041
6042fn extract_event_query_event_id_from_text(text: &str) -> Option<u32> {
6043    let re = regex::Regex::new(r"(?i)\bevent(?:\s*_?\s*id)?\s*[:#]?\s*(\d{2,5})\b").ok()?;
6044    re.captures(text)
6045        .and_then(|captures| captures.get(1))
6046        .and_then(|m| m.as_str().parse::<u32>().ok())
6047}
6048
6049fn extract_event_query_log_from_text(text: &str) -> Option<&'static str> {
6050    let lower = text.to_ascii_lowercase();
6051    if lower.contains("security log") {
6052        Some("Security")
6053    } else if lower.contains("application log") {
6054        Some("Application")
6055    } else if lower.contains("system log") || lower.contains("system errors") {
6056        Some("System")
6057    } else if lower.contains("setup log") {
6058        Some("Setup")
6059    } else {
6060        None
6061    }
6062}
6063
6064fn extract_event_query_level_from_text(text: &str) -> Option<&'static str> {
6065    let lower = text.to_ascii_lowercase();
6066    if lower.contains("critical") {
6067        Some("Critical")
6068    } else if lower.contains("error") || lower.contains("errors") {
6069        Some("Error")
6070    } else if lower.contains("warning") || lower.contains("warnings") || lower.contains("warn") {
6071        Some("Warning")
6072    } else if lower.contains("information")
6073        || lower.contains("informational")
6074        || lower.contains("info")
6075    {
6076        Some("Information")
6077    } else {
6078        None
6079    }
6080}
6081
6082fn extract_event_query_hours_from_text(text: &str) -> Option<u32> {
6083    let lower = text.to_ascii_lowercase();
6084    let re = regex::Regex::new(r"(?i)\b(?:last|past)\s+(\d{1,3})\s*(hour|hours|hr|hrs)\b").ok()?;
6085    if let Some(hours) = re
6086        .captures(&lower)
6087        .and_then(|captures| captures.get(1))
6088        .and_then(|m| m.as_str().parse::<u32>().ok())
6089    {
6090        return Some(hours);
6091    }
6092    if lower.contains("last hour") || lower.contains("past hour") {
6093        Some(1)
6094    } else if lower.contains("today") {
6095        Some(24)
6096    } else {
6097        None
6098    }
6099}
6100
6101fn extract_dns_record_type_from_shell(command: &str) -> Option<&'static str> {
6102    let lower = command.to_ascii_lowercase();
6103    if lower.contains("-type aaaa") || lower.contains("-type=aaaa") {
6104        Some("AAAA")
6105    } else if lower.contains("-type mx") || lower.contains("-type=mx") {
6106        Some("MX")
6107    } else if lower.contains("-type srv") || lower.contains("-type=srv") {
6108        Some("SRV")
6109    } else if lower.contains("-type txt") || lower.contains("-type=txt") {
6110        Some("TXT")
6111    } else if lower.contains("-type cname") || lower.contains("-type=cname") {
6112        Some("CNAME")
6113    } else if lower.contains("-type soa") || lower.contains("-type=soa") {
6114        Some("SOA")
6115    } else if lower.contains("-type ptr") || lower.contains("-type=ptr") {
6116        Some("PTR")
6117    } else if lower.contains("-type a") || lower.contains("-type=a") {
6118        Some("A")
6119    } else {
6120        extract_dns_record_type_from_text(command)
6121    }
6122}
6123
6124fn host_inspection_args_from_prompt(topic: &str, prompt: &str) -> Value {
6125    let mut args = serde_json::json!({ "topic": topic });
6126    if topic == "dns_lookup" {
6127        if let Some(name) = extract_dns_lookup_target_from_text(prompt) {
6128            args.as_object_mut()
6129                .unwrap()
6130                .insert("name".to_string(), Value::String(name));
6131        }
6132        let record_type = extract_dns_record_type_from_text(prompt).unwrap_or("A");
6133        args.as_object_mut()
6134            .unwrap()
6135            .insert("type".to_string(), Value::String(record_type.to_string()));
6136    } else if topic == "event_query" {
6137        if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
6138            args.as_object_mut().unwrap().insert(
6139                "event_id".to_string(),
6140                Value::Number(serde_json::Number::from(event_id)),
6141            );
6142        }
6143        if let Some(log_name) = extract_event_query_log_from_text(prompt) {
6144            args.as_object_mut()
6145                .unwrap()
6146                .insert("log".to_string(), Value::String(log_name.to_string()));
6147        }
6148        if let Some(level) = extract_event_query_level_from_text(prompt) {
6149            args.as_object_mut()
6150                .unwrap()
6151                .insert("level".to_string(), Value::String(level.to_string()));
6152        }
6153        if let Some(hours) = extract_event_query_hours_from_text(prompt) {
6154            args.as_object_mut().unwrap().insert(
6155                "hours".to_string(),
6156                Value::Number(serde_json::Number::from(hours)),
6157            );
6158        }
6159    }
6160    args
6161}
6162
6163fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
6164    let workflow = preferred_maintainer_workflow(prompt)?;
6165    let lower = prompt.to_ascii_lowercase();
6166    match workflow {
6167        "clean" => Some(serde_json::json!({
6168            "workflow": "clean",
6169            "deep": lower.contains("deep clean")
6170                || lower.contains("deep cleanup")
6171                || lower.contains("deep"),
6172            "reset": lower.contains("reset"),
6173            "prune_dist": lower.contains("prune dist")
6174                || lower.contains("prune old dist")
6175                || lower.contains("prune old artifacts")
6176                || lower.contains("old dist artifacts")
6177                || lower.contains("old artifacts"),
6178        })),
6179        "package_windows" => Some(serde_json::json!({
6180            "workflow": "package_windows",
6181            "installer": lower.contains("installer") || lower.contains("setup.exe"),
6182            "add_to_path": lower.contains("addtopath")
6183                || lower.contains("add to path")
6184                || lower.contains("update path")
6185                || lower.contains("refresh path"),
6186        })),
6187        "release" => {
6188            let version = regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#)
6189                .ok()
6190                .and_then(|re| re.captures(prompt))
6191                .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
6192            let bump = if lower.contains("patch") {
6193                Some("patch")
6194            } else if lower.contains("minor") {
6195                Some("minor")
6196            } else if lower.contains("major") {
6197                Some("major")
6198            } else {
6199                None
6200            };
6201            let mut args = serde_json::json!({
6202                "workflow": "release",
6203                "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
6204                "add_to_path": lower.contains("addtopath")
6205                    || lower.contains("add to path")
6206                    || lower.contains("update path"),
6207                "skip_installer": lower.contains("skip installer"),
6208                "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
6209                "publish_voice_crate": lower.contains("publish voice crate")
6210                    || lower.contains("publish hematite-kokoros"),
6211            });
6212            if let Some(version) = version {
6213                args["version"] = Value::String(version);
6214            }
6215            if let Some(bump) = bump {
6216                args["bump"] = Value::String(bump.to_string());
6217            }
6218            Some(args)
6219        }
6220        _ => None,
6221    }
6222}
6223
6224fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
6225    if is_scaffold_request(prompt) {
6226        return None;
6227    }
6228    let workflow = preferred_workspace_workflow(prompt)?;
6229    let lower = prompt.to_ascii_lowercase();
6230    let trimmed = prompt.trim();
6231
6232    if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
6233        return Some(serde_json::json!({
6234            "workflow": "command",
6235            "command": command,
6236        }));
6237    }
6238
6239    if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
6240        return Some(serde_json::json!({
6241            "workflow": "script_path",
6242            "path": path,
6243        }));
6244    }
6245
6246    match workflow {
6247        "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
6248            "workflow": workflow,
6249        })),
6250        "script" => {
6251            let package_script = if lower.contains("npm run ") {
6252                extract_word_after(&lower, "npm run ")
6253            } else if lower.contains("pnpm run ") {
6254                extract_word_after(&lower, "pnpm run ")
6255            } else if lower.contains("bun run ") {
6256                extract_word_after(&lower, "bun run ")
6257            } else if lower.contains("yarn ") {
6258                extract_word_after(&lower, "yarn ")
6259            } else {
6260                None
6261            };
6262
6263            if let Some(name) = package_script {
6264                return Some(serde_json::json!({
6265                    "workflow": "package_script",
6266                    "name": name,
6267                }));
6268            }
6269
6270            if let Some(name) = extract_word_after(&lower, "just ") {
6271                return Some(serde_json::json!({
6272                    "workflow": "just",
6273                    "name": name,
6274                }));
6275            }
6276            if let Some(name) = extract_word_after(&lower, "make ") {
6277                return Some(serde_json::json!({
6278                    "workflow": "make",
6279                    "name": name,
6280                }));
6281            }
6282            if let Some(name) = extract_word_after(&lower, "task ") {
6283                return Some(serde_json::json!({
6284                    "workflow": "task",
6285                    "name": name,
6286                }));
6287            }
6288
6289            None
6290        }
6291        _ => None,
6292    }
6293}
6294
6295fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
6296    let lower = prompt.to_ascii_lowercase();
6297    for prefix in [
6298        "cargo ",
6299        "npm ",
6300        "pnpm ",
6301        "yarn ",
6302        "bun ",
6303        "pytest",
6304        "go build",
6305        "go test",
6306        "make ",
6307        "just ",
6308        "task ",
6309        "./gradlew",
6310        ".\\gradlew",
6311    ] {
6312        if let Some(index) = lower.find(prefix) {
6313            return Some(prompt[index..].trim().trim_matches('`').to_string());
6314        }
6315    }
6316    None
6317}
6318
6319fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
6320    let normalized = prompt.replace('\\', "/");
6321    for token in normalized.split_whitespace() {
6322        let candidate = token
6323            .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
6324            .trim_start_matches("./");
6325        if candidate.starts_with("scripts/")
6326            && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
6327                .iter()
6328                .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
6329        {
6330            return Some(candidate.to_string());
6331        }
6332    }
6333    None
6334}
6335
6336fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
6337    let start = haystack.find(prefix)? + prefix.len();
6338    let tail = &haystack[start..];
6339    let word = tail
6340        .split_whitespace()
6341        .next()
6342        .map(str::trim)
6343        .filter(|value| !value.is_empty())?;
6344    Some(
6345        word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
6346            .to_string(),
6347    )
6348}
6349
6350fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
6351    let lower = command.to_ascii_lowercase();
6352    if lower.contains("clean.ps1") {
6353        return Some(serde_json::json!({
6354            "workflow": "clean",
6355            "deep": lower.contains("-deep"),
6356            "reset": lower.contains("-reset"),
6357            "prune_dist": lower.contains("-prunedist"),
6358        }));
6359    }
6360    if lower.contains("package-windows.ps1") {
6361        return Some(serde_json::json!({
6362            "workflow": "package_windows",
6363            "installer": lower.contains("-installer"),
6364            "add_to_path": lower.contains("-addtopath"),
6365        }));
6366    }
6367    if lower.contains("release.ps1") {
6368        let version = extract_release_arg(command, "-Version");
6369        let bump = extract_release_arg(command, "-Bump");
6370        if version.is_none() && bump.is_none() {
6371            return Some(serde_json::json!({
6372                "workflow": "release"
6373            }));
6374        }
6375        let mut args = serde_json::json!({
6376            "workflow": "release",
6377            "push": lower.contains("-push"),
6378            "add_to_path": lower.contains("-addtopath"),
6379            "skip_installer": lower.contains("-skipinstaller"),
6380            "publish_crates": lower.contains("-publishcrates"),
6381            "publish_voice_crate": lower.contains("-publishvoicecrate"),
6382        });
6383        if let Some(version) = version {
6384            args["version"] = Value::String(version);
6385        }
6386        if let Some(bump) = bump {
6387            args["bump"] = Value::String(bump);
6388        }
6389        return Some(args);
6390    }
6391    None
6392}
6393
6394fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
6395    let lower = command.to_ascii_lowercase();
6396    if lower.contains("clean.ps1")
6397        || lower.contains("package-windows.ps1")
6398        || lower.contains("release.ps1")
6399    {
6400        return None;
6401    }
6402
6403    if let Some(path) = extract_workspace_script_path_from_prompt(command) {
6404        return Some(serde_json::json!({
6405            "workflow": "script_path",
6406            "path": path,
6407        }));
6408    }
6409
6410    let looks_like_workspace_command = [
6411        "cargo ",
6412        "npm ",
6413        "pnpm ",
6414        "yarn ",
6415        "bun ",
6416        "pytest",
6417        "go build",
6418        "go test",
6419        "make ",
6420        "just ",
6421        "task ",
6422        "./gradlew",
6423        ".\\gradlew",
6424    ]
6425    .iter()
6426    .any(|needle| lower.contains(needle));
6427
6428    if looks_like_workspace_command {
6429        Some(serde_json::json!({
6430            "workflow": "command",
6431            "command": command.trim(),
6432        }))
6433    } else {
6434        None
6435    }
6436}
6437
6438fn rewrite_host_tool_call(
6439    tool_name: &mut String,
6440    args: &mut Value,
6441    latest_user_prompt: Option<&str>,
6442) {
6443    if *tool_name == "shell" {
6444        let command = args
6445            .get("command")
6446            .and_then(|value| value.as_str())
6447            .unwrap_or("");
6448        if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
6449            *tool_name = "run_hematite_maintainer_workflow".to_string();
6450            *args = maintainer_workflow_args;
6451            return;
6452        }
6453        if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
6454            *tool_name = "run_workspace_workflow".to_string();
6455            *args = workspace_workflow_args;
6456            return;
6457        }
6458    }
6459    let is_surgical_tool = matches!(
6460        tool_name.as_str(),
6461        "create_directory"
6462            | "write_file"
6463            | "edit_file"
6464            | "patch_hunk"
6465            | "multi_replace_file_content"
6466            | "replace_file_content"
6467            | "move_file"
6468            | "delete_file"
6469    );
6470
6471    if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
6472        if let Some(prompt_args) =
6473            latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
6474        {
6475            *tool_name = "run_hematite_maintainer_workflow".to_string();
6476            *args = prompt_args;
6477            return;
6478        }
6479    }
6480    // Only allow auto-rewrite for generic shell/command triggers.
6481    // We NEVER rewrite surgical tools (write/edit) or evidence tools (read/inspect)
6482    // because that leads to inference-hijack loops.
6483    let is_generic_command_trigger = matches!(
6484        tool_name.as_str(),
6485        "shell" | "run_command" | "workflow" | "run"
6486    );
6487    if is_generic_command_trigger && *tool_name != "run_workspace_workflow" {
6488        if let Some(prompt_args) =
6489            latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
6490        {
6491            *tool_name = "run_workspace_workflow".to_string();
6492            *args = prompt_args;
6493            return;
6494        }
6495    }
6496    if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
6497        *tool_name = "inspect_host".to_string();
6498        *args = serde_json::json!({
6499            "topic": "fix_plan"
6500        });
6501    }
6502    fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
6503    fill_missing_dns_lookup_name(tool_name, args, latest_user_prompt);
6504    fill_missing_dns_lookup_type(tool_name, args, latest_user_prompt);
6505    fill_missing_event_query_args(tool_name, args, latest_user_prompt);
6506}
6507
6508fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
6509    format!(
6510        "{}:{}",
6511        tool_name,
6512        serde_json::to_string(args).unwrap_or_default()
6513    )
6514}
6515
6516fn normalized_tool_call_for_execution(
6517    tool_name: &str,
6518    raw_arguments: &str,
6519    gemma4_model: bool,
6520    latest_user_prompt: Option<&str>,
6521) -> (String, Value) {
6522    let normalized_arguments = if gemma4_model {
6523        crate::agent::inference::normalize_tool_argument_string(tool_name, raw_arguments)
6524    } else {
6525        raw_arguments.to_string()
6526    };
6527    let mut normalized_name = tool_name.to_string();
6528    let mut args = serde_json::from_str::<Value>(&normalized_arguments)
6529        .unwrap_or(Value::Object(Default::default()));
6530    rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
6531    (normalized_name, args)
6532}
6533
6534#[cfg(test)]
6535fn normalized_tool_call_key_for_dedupe(
6536    tool_name: &str,
6537    raw_arguments: &str,
6538    gemma4_model: bool,
6539    latest_user_prompt: Option<&str>,
6540) -> String {
6541    let (normalized_name, args) = normalized_tool_call_for_execution(
6542        tool_name,
6543        raw_arguments,
6544        gemma4_model,
6545        latest_user_prompt,
6546    );
6547    canonical_tool_call_key(&normalized_name, &args)
6548}
6549
6550impl ConversationManager {
6551    /// Checks if a tool call is authorized given the current configuration and mode.
6552    fn check_authorization(
6553        &self,
6554        name: &str,
6555        args: &serde_json::Value,
6556        config: &crate::agent::config::HematiteConfig,
6557        yolo_flag: bool,
6558    ) -> crate::agent::permission_enforcer::AuthorizationDecision {
6559        crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
6560    }
6561
6562    /// Layer 4: Isolated tool execution logic. Does not mutate 'self' to allow parallelism.
6563    async fn process_tool_call(
6564        &self,
6565        mut call: ToolCallFn,
6566        config: crate::agent::config::HematiteConfig,
6567        yolo: bool,
6568        tx: mpsc::Sender<InferenceEvent>,
6569        real_id: String,
6570    ) -> ToolExecutionOutcome {
6571        let mut msg_results = Vec::new();
6572        let mut latest_target_dir = None;
6573        let mut plan_drafted_this_turn = false;
6574        let mut parsed_plan_handoff = None;
6575        let gemma4_model =
6576            crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
6577        let normalized_arguments = if gemma4_model {
6578            crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments)
6579        } else {
6580            call.arguments.clone()
6581        };
6582
6583        // 1. Argument Parsing & Repair
6584        let mut args: Value = match serde_json::from_str(&normalized_arguments) {
6585            Ok(v) => v,
6586            Err(_) => {
6587                match self
6588                    .repair_tool_args(&call.name, &normalized_arguments, &tx)
6589                    .await
6590                {
6591                    Ok(v) => v,
6592                    Err(e) => {
6593                        let _ = tx
6594                            .send(InferenceEvent::Thought(format!(
6595                                "JSON Repair failed: {}",
6596                                e
6597                            )))
6598                            .await;
6599                        Value::Object(Default::default())
6600                    }
6601                }
6602            }
6603        };
6604        let last_user_prompt = self
6605            .history
6606            .iter()
6607            .rev()
6608            .find(|message| message.role == "user")
6609            .map(|message| message.content.as_str());
6610        rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
6611
6612        let display = format_tool_display(&call.name, &args);
6613        let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
6614        let auth = self.check_authorization(&call.name, &args, &config, yolo);
6615
6616        // 2. Permission Check
6617        let decision_result = match precondition_result {
6618            Err(e) => Err(e),
6619            Ok(_) => match auth {
6620                crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
6621                crate::agent::permission_enforcer::AuthorizationDecision::Ask {
6622                    reason,
6623                    source: _,
6624                } => {
6625                    let mutation_label =
6626                        crate::agent::tool_registry::get_mutation_label(&call.name, &args);
6627                    let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
6628                    let _ = tx
6629                        .send(InferenceEvent::ApprovalRequired {
6630                            id: real_id.clone(),
6631                            name: call.name.clone(),
6632                            display: format!("{}\nWhy: {}", display, reason),
6633                            diff: None,
6634                            mutation_label,
6635                            responder: approve_tx,
6636                        })
6637                        .await;
6638
6639                    match approve_rx.await {
6640                        Ok(true) => Ok(()),
6641                        _ => Err("Declined by user".into()),
6642                    }
6643                }
6644                crate::agent::permission_enforcer::AuthorizationDecision::Deny {
6645                    reason, ..
6646                } => Err(reason),
6647            },
6648        };
6649        let blocked_by_policy =
6650            matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
6651
6652        // 3. Execution (Local or MCP)
6653        let (output, is_error) = match decision_result {
6654            Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
6655            Err(e) => (format!("Error: {}", e), true),
6656            Ok(_) => {
6657                let _ = tx
6658                    .send(InferenceEvent::ToolCallStart {
6659                        id: real_id.clone(),
6660                        name: call.name.clone(),
6661                        args: display.clone(),
6662                    })
6663                    .await;
6664
6665                let result = if call.name.starts_with("lsp_") {
6666                    let lsp = self.lsp_manager.clone();
6667                    let path = args
6668                        .get("path")
6669                        .and_then(|v| v.as_str())
6670                        .unwrap_or("")
6671                        .to_string();
6672                    let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
6673                    let character =
6674                        args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
6675
6676                    match call.name.as_str() {
6677                        "lsp_definitions" => {
6678                            crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
6679                                .await
6680                        }
6681                        "lsp_references" => {
6682                            crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
6683                                .await
6684                        }
6685                        "lsp_hover" => {
6686                            crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
6687                        }
6688                        "lsp_search_symbol" => {
6689                            let query = args
6690                                .get("query")
6691                                .and_then(|v| v.as_str())
6692                                .unwrap_or_default()
6693                                .to_string();
6694                            crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
6695                        }
6696                        "lsp_rename_symbol" => {
6697                            let new_name = args
6698                                .get("new_name")
6699                                .and_then(|v| v.as_str())
6700                                .unwrap_or_default()
6701                                .to_string();
6702                            crate::tools::lsp_tools::lsp_rename_symbol(
6703                                lsp, path, line, character, new_name,
6704                            )
6705                            .await
6706                        }
6707                        "lsp_get_diagnostics" => {
6708                            crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
6709                        }
6710                        _ => Err(format!("Unknown LSP tool: {}", call.name)),
6711                    }
6712                } else if call.name == "auto_pin_context" {
6713                    let pts = args.get("paths").and_then(|v| v.as_array());
6714                    let reason = args
6715                        .get("reason")
6716                        .and_then(|v| v.as_str())
6717                        .unwrap_or("uninformed scoping");
6718                    if let Some(arr) = pts {
6719                        let mut pinned = Vec::new();
6720                        {
6721                            let mut guard = self.pinned_files.lock().await;
6722                            const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; // 25MB Safety Valve
6723
6724                            for v in arr.iter().take(3) {
6725                                if let Some(p) = v.as_str() {
6726                                    if let Ok(meta) = std::fs::metadata(p) {
6727                                        if meta.len() > MAX_PINNED_SIZE {
6728                                            let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
6729                                            continue;
6730                                        }
6731                                        if let Ok(content) = std::fs::read_to_string(p) {
6732                                            guard.insert(p.to_string(), content);
6733                                            pinned.push(p.to_string());
6734                                        }
6735                                    }
6736                                }
6737                            }
6738                        }
6739                        let msg = format!(
6740                            "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
6741                            pinned.join(", "),
6742                            reason
6743                        );
6744                        let _ = tx
6745                            .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
6746                            .await;
6747                        Ok(msg)
6748                    } else {
6749                        Err("Missing 'paths' array for auto_pin_context.".to_string())
6750                    }
6751                } else if call.name == "list_pinned" {
6752                    let paths_msg = {
6753                        let pinned = self.pinned_files.lock().await;
6754                        if pinned.is_empty() {
6755                            "No files are currently pinned.".to_string()
6756                        } else {
6757                            let paths: Vec<_> = pinned.keys().cloned().collect();
6758                            format!(
6759                                "Currently pinned files in active memory:\n- {}",
6760                                paths.join("\n- ")
6761                            )
6762                        }
6763                    };
6764                    Ok(paths_msg)
6765                } else if call.name.starts_with("mcp__") {
6766                    let mut mcp = self.mcp_manager.lock().await;
6767                    match mcp.call_tool(&call.name, &args).await {
6768                        Ok(res) => Ok(res),
6769                        Err(e) => Err(e.to_string()),
6770                    }
6771                } else if call.name == "swarm" {
6772                    // ── Swarm Orchestration ──
6773                    let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
6774                    let max_workers = args
6775                        .get("max_workers")
6776                        .and_then(|v| v.as_u64())
6777                        .unwrap_or(3) as usize;
6778
6779                    let mut task_objs = Vec::new();
6780                    if let Value::Array(arr) = tasks_val {
6781                        for v in arr {
6782                            let id = v
6783                                .get("id")
6784                                .and_then(|x| x.as_str())
6785                                .unwrap_or("?")
6786                                .to_string();
6787                            let target = v
6788                                .get("target")
6789                                .and_then(|x| x.as_str())
6790                                .unwrap_or("?")
6791                                .to_string();
6792                            let instruction = v
6793                                .get("instruction")
6794                                .and_then(|x| x.as_str())
6795                                .unwrap_or("?")
6796                                .to_string();
6797                            task_objs.push(crate::agent::parser::WorkerTask {
6798                                id,
6799                                target,
6800                                instruction,
6801                            });
6802                        }
6803                    }
6804
6805                    if task_objs.is_empty() {
6806                        Err("No tasks provided for swarm.".to_string())
6807                    } else {
6808                        let (swarm_tx_internal, mut swarm_rx_internal) =
6809                            tokio::sync::mpsc::channel(32);
6810                        let tx_forwarder = tx.clone();
6811
6812                        // Bridge SwarmMessage -> InferenceEvent
6813                        tokio::spawn(async move {
6814                            while let Some(msg) = swarm_rx_internal.recv().await {
6815                                match msg {
6816                                    crate::agent::swarm::SwarmMessage::Progress(id, p) => {
6817                                        let _ = tx_forwarder
6818                                            .send(InferenceEvent::Thought(format!(
6819                                                "Swarm [{}]: {}% complete",
6820                                                id, p
6821                                            )))
6822                                            .await;
6823                                    }
6824                                    crate::agent::swarm::SwarmMessage::ReviewRequest {
6825                                        worker_id,
6826                                        file_path,
6827                                        before: _,
6828                                        after: _,
6829                                        tx,
6830                                    } => {
6831                                        let (approve_tx, approve_rx) =
6832                                            tokio::sync::oneshot::channel::<bool>();
6833                                        let display = format!(
6834                                            "Swarm worker [{}]: Integrated changes into {:?}",
6835                                            worker_id, file_path
6836                                        );
6837                                        let _ = tx_forwarder
6838                                            .send(InferenceEvent::ApprovalRequired {
6839                                                id: format!("swarm_{}", worker_id),
6840                                                name: "swarm_apply".to_string(),
6841                                                display,
6842                                                diff: None,
6843                                                mutation_label: Some(
6844                                                    "Swarm Agentic Integration".to_string(),
6845                                                ),
6846                                                responder: approve_tx,
6847                                            })
6848                                            .await;
6849                                        if let Ok(approved) = approve_rx.await {
6850                                            let response = if approved {
6851                                                crate::agent::swarm::ReviewResponse::Accept
6852                                            } else {
6853                                                crate::agent::swarm::ReviewResponse::Reject
6854                                            };
6855                                            let _ = tx.send(response);
6856                                        }
6857                                    }
6858                                    crate::agent::swarm::SwarmMessage::Done => {}
6859                                }
6860                            }
6861                        });
6862
6863                        let coordinator = self.swarm_coordinator.clone();
6864                        match coordinator
6865                            .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
6866                            .await
6867                        {
6868                            Ok(_) => Ok(
6869                                "Swarm execution completed. Check files for integration results."
6870                                    .to_string(),
6871                            ),
6872                            Err(e) => Err(format!("Swarm failure: {}", e)),
6873                        }
6874                    }
6875                } else if call.name == "vision_analyze" {
6876                    crate::tools::vision::vision_analyze(&self.engine, &args).await
6877                } else if matches!(
6878                    call.name.as_str(),
6879                    "edit_file" | "patch_hunk" | "multi_search_replace" | "write_file"
6880                ) && !yolo
6881                {
6882                    // ── Diff preview gate ─────────────────────────────────────
6883                    // Compute what the edit would look like before applying it.
6884                    // If we can build a diff, require user Y/N in the TUI.
6885                    // write_file shows the full new content as additions (new files)
6886                    // or a before/after replacement (overwriting existing files).
6887                    let diff_result = match call.name.as_str() {
6888                        "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
6889                        "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
6890                        "write_file" => crate::tools::file_ops::compute_write_file_diff(&args),
6891                        _ => crate::tools::file_ops::compute_msr_diff(&args),
6892                    };
6893                    match diff_result {
6894                        Ok(diff_text) => {
6895                            let path_label =
6896                                args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
6897                            let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
6898                            let mutation_label =
6899                                crate::agent::tool_registry::get_mutation_label(&call.name, &args);
6900                            let _ = tx
6901                                .send(InferenceEvent::ApprovalRequired {
6902                                    id: real_id.clone(),
6903                                    name: call.name.clone(),
6904                                    display: format!("Edit preview: {}", path_label),
6905                                    diff: Some(diff_text),
6906                                    mutation_label,
6907                                    responder: appr_tx,
6908                                })
6909                                .await;
6910                            match appr_rx.await {
6911                                Ok(true) => dispatch_tool(&call.name, &args, &config).await,
6912                                _ => Err("Edit declined by user.".into()),
6913                            }
6914                        }
6915                        // Diff computation failed (e.g. search string not found yet) —
6916                        // fall through and let the tool return its own error.
6917                        Err(_) => dispatch_tool(&call.name, &args, &config).await,
6918                    }
6919                } else if call.name == "verify_build" {
6920                    // Stream build output line-by-line to the SPECULAR panel so
6921                    // the operator sees live compiler progress during long builds.
6922                    crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
6923                } else if call.name == "shell" {
6924                    // Stream shell output line-by-line to the SPECULAR panel so
6925                    // the operator sees live progress during long commands.
6926                    crate::tools::shell::execute_streaming(&args, tx.clone()).await
6927                } else {
6928                    dispatch_tool(&call.name, &args, &config).await
6929                };
6930
6931                match result {
6932                    Ok(o) => (o, false),
6933                    Err(e) => (format!("Error: {}", e), true),
6934                }
6935            }
6936        };
6937
6938        // ── Session Economics ────────────────────────────────────────────────
6939        {
6940            if let Ok(mut econ) = self.engine.economics.lock() {
6941                econ.record_tool(&call.name, !is_error);
6942            }
6943        }
6944
6945        if !is_error {
6946            if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
6947                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
6948                    if call.name == "inspect_lines" {
6949                        self.record_line_inspection(path).await;
6950                    } else {
6951                        self.record_read_observation(path).await;
6952                    }
6953                }
6954            }
6955
6956            if call.name == "verify_build" {
6957                let ok = output.contains("BUILD OK")
6958                    || output.contains("BUILD SUCCESS")
6959                    || output.contains("BUILD OKAY");
6960                self.record_verify_build_result(ok, &output).await;
6961            }
6962
6963            if matches!(
6964                call.name.as_str(),
6965                "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
6966            ) || is_mcp_mutating_tool(&call.name)
6967            {
6968                if call.name == "write_file" {
6969                    if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
6970                        if path.ends_with("PLAN.md") {
6971                            plan_drafted_this_turn = true;
6972                            if !is_error {
6973                                if let Some(content) = args.get("content").and_then(|v| v.as_str())
6974                                {
6975                                    let resolved = crate::tools::file_ops::resolve_candidate(path);
6976                                    let _ = crate::tools::plan::sync_plan_blueprint_for_path(
6977                                        &resolved, content,
6978                                    );
6979                                    parsed_plan_handoff =
6980                                        crate::tools::plan::parse_plan_handoff(content);
6981                                }
6982                            }
6983                        }
6984                    }
6985                }
6986                self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
6987                    .await;
6988            }
6989
6990            if call.name == "create_directory" {
6991                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
6992                    let resolved = crate::tools::file_ops::resolve_candidate(path);
6993                    latest_target_dir = Some(resolved.to_string_lossy().to_string());
6994                }
6995            }
6996
6997            if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
6998                msg_results.push(receipt);
6999            }
7000        }
7001
7002        // 4. Critic Check (Specular Tier 2)
7003        // Gated: skipped in yolo mode (fast path), only runs on code files with
7004        // substantive content to avoid burning tokens on trivial doc/config edits.
7005        if !is_error && !yolo && (call.name == "edit_file" || call.name == "write_file") {
7006            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
7007            let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
7008            let ext = std::path::Path::new(path)
7009                .extension()
7010                .and_then(|e| e.to_str())
7011                .unwrap_or("");
7012            const SKIP_EXTS: &[&str] = &[
7013                "md",
7014                "toml",
7015                "json",
7016                "txt",
7017                "yml",
7018                "yaml",
7019                "cfg",
7020                "csv",
7021                "lock",
7022                "gitignore",
7023            ];
7024            let line_count = content.lines().count();
7025            // Web files always get reviewed regardless of length — a 20-line HTML
7026            // skeleton can still be missing DOCTYPE, meta charset, or linked CSS.
7027            const WEB_EXTS: &[&str] = &[
7028                "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
7029            ];
7030            let is_web = WEB_EXTS.contains(&ext);
7031            let min_lines = if is_web { 5 } else { 50 };
7032            if !path.is_empty()
7033                && !content.is_empty()
7034                && !SKIP_EXTS.contains(&ext)
7035                && line_count >= min_lines
7036            {
7037                if let Some(critique) = self.run_critic_check(path, content, &tx).await {
7038                    msg_results.push(ChatMessage::system(&format!(
7039                        "[CRITIC AUTO-FIX REQUIRED — {}]\n\
7040                        Fix ALL issues below before sending your final response. \
7041                        Call the appropriate edit tools now.\n\n{}",
7042                        path, critique
7043                    )));
7044                }
7045            }
7046        }
7047
7048        ToolExecutionOutcome {
7049            call_id: real_id,
7050            tool_name: call.name,
7051            args,
7052            output,
7053            is_error,
7054            blocked_by_policy,
7055            msg_results,
7056            latest_target_dir,
7057            plan_drafted_this_turn,
7058            parsed_plan_handoff,
7059        }
7060    }
7061}
7062
7063/// The result of an isolated tool execution.
7064/// Used to bridge Parallel/Serial execution back to the main history.
7065struct ToolExecutionOutcome {
7066    call_id: String,
7067    tool_name: String,
7068    args: Value,
7069    output: String,
7070    is_error: bool,
7071    blocked_by_policy: bool,
7072    msg_results: Vec<ChatMessage>,
7073    latest_target_dir: Option<String>,
7074    plan_drafted_this_turn: bool,
7075    parsed_plan_handoff: Option<crate::tools::plan::PlanHandoff>,
7076}
7077
7078#[derive(Clone)]
7079struct CachedToolResult {
7080    tool_name: String,
7081}
7082
7083fn is_code_like_path(path: &str) -> bool {
7084    let ext = std::path::Path::new(path)
7085        .extension()
7086        .and_then(|e| e.to_str())
7087        .unwrap_or("")
7088        .to_ascii_lowercase();
7089    matches!(
7090        ext.as_str(),
7091        "rs" | "js"
7092            | "ts"
7093            | "tsx"
7094            | "jsx"
7095            | "py"
7096            | "go"
7097            | "java"
7098            | "c"
7099            | "cpp"
7100            | "cc"
7101            | "h"
7102            | "hpp"
7103            | "cs"
7104            | "swift"
7105            | "kt"
7106            | "kts"
7107            | "rb"
7108            | "php"
7109    )
7110}
7111
7112// ── Display helpers ───────────────────────────────────────────────────────────
7113
7114pub fn format_tool_display(name: &str, args: &Value) -> String {
7115    let get = |key: &str| {
7116        args.get(key)
7117            .and_then(|v| v.as_str())
7118            .unwrap_or("")
7119            .to_string()
7120    };
7121    match name {
7122        "shell" | "bash" | "powershell" => format!("$ {}", get("command")),
7123        "run_workspace_workflow" => format!("workflow: {}", get("workflow")),
7124        "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
7125        "describe_toolchain" => format!("describe toolchain {}", get("topic")),
7126        "inspect_host" => format!("inspect host {}", get("topic")),
7127        "write_file"
7128        | "read_file"
7129        | "edit_file"
7130        | "patch_hunk"
7131        | "inspect_lines"
7132        | "lsp_get_diagnostics" => format!("{} `{}`", name, get("path")),
7133        "grep_files" => format!(
7134            "grep_files pattern='{}' path='{}'",
7135            get("pattern"),
7136            get("path")
7137        ),
7138        "list_files" => format!("list_files `{}`", get("path")),
7139        "multi_search_replace" => format!("multi_search_replace `{}`", get("path")),
7140        _ => {
7141            // Keep generic debug output strictly bounded so it never desyncs the TUI scroll math
7142            let rep = format!("{} {:?}", name, args);
7143            if rep.len() > 100 {
7144                format!("{}... (truncated)", &rep[..100])
7145            } else {
7146                rep
7147            }
7148        }
7149    }
7150}
7151
7152// ── Text utilities ────────────────────────────────────────────────────────────
7153
7154pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
7155    let lower = command.to_ascii_lowercase();
7156    [
7157        "$env:path",
7158        "pathvariable",
7159        "pip --version",
7160        "pipx --version",
7161        "winget --version",
7162        "choco",
7163        "scoop",
7164        "get-childitem",
7165        "gci ",
7166        "where.exe",
7167        "where ",
7168        "cargo --version",
7169        "rustc --version",
7170        "git --version",
7171        "node --version",
7172        "npm --version",
7173        "pnpm --version",
7174        "python --version",
7175        "python3 --version",
7176        "deno --version",
7177        "go version",
7178        "dotnet --version",
7179        "uv --version",
7180        "netstat",
7181        "findstr",
7182        "get-nettcpconnection",
7183        "tcpconnection",
7184        "listening",
7185        "ss -",
7186        "ss ",
7187        "lsof",
7188        "tasklist",
7189        "ipconfig",
7190        "get-netipconfiguration",
7191        "get-netadapter",
7192        "route print",
7193        "ifconfig",
7194        "ip addr",
7195        "ip route",
7196        "resolv.conf",
7197        "get-service",
7198        "sc query",
7199        "systemctl",
7200        "service --status-all",
7201        "get-process",
7202        "working set",
7203        "ps -eo",
7204        "ps aux",
7205        "desktop",
7206        "downloads",
7207        "get-netfirewallprofile",
7208        "win32_powerplan",
7209        "win32_operatingsystem",
7210        "win32_processor",
7211        "wmic",
7212        "loadpercentage",
7213        "totalvisiblememory",
7214        "freephysicalmemory",
7215        "get-wmiobject",
7216        "get-ciminstance",
7217        "get-cpu",
7218        "processorname",
7219        "clockspeed",
7220        "top memory",
7221        "top cpu",
7222        "resource usage",
7223        "powercfg",
7224        "uptime",
7225        "lastbootuptime",
7226        // registry reads for OS/version/update/security info — always use inspect_host
7227        "hklm:",
7228        "hkcu:",
7229        "hklm:\\",
7230        "hkcu:\\",
7231        "currentversion",
7232        "productname",
7233        "displayversion",
7234        "get-itemproperty",
7235        "get-itempropertyvalue",
7236        // updates
7237        "get-windowsupdatelog",
7238        "windowsupdatelog",
7239        "microsoft.update.session",
7240        "createupdatesearcher",
7241        "wuauserv",
7242        "usoclient",
7243        "get-hotfix",
7244        "wu_",
7245        // security / defender
7246        "get-mpcomputerstatus",
7247        "get-mppreference",
7248        "get-mpthreat",
7249        "start-mpscan",
7250        "win32_computersecurity",
7251        "softwarelicensingproduct",
7252        "enablelua",
7253        "get-netfirewallrule",
7254        "netfirewallprofile",
7255        "antivirus",
7256        "defenderstatus",
7257        // disk health / smart
7258        "get-physicaldisk",
7259        "get-disk",
7260        "get-volume",
7261        "get-psdrive",
7262        "psdrive",
7263        "manage-bde",
7264        "bitlockervolume",
7265        "get-bitlockervolume",
7266        "get-smbencryptionstatus",
7267        "smbencryption",
7268        "get-netlanmanagerconnection",
7269        "lanmanager",
7270        "msstoragedriver_failurepredic",
7271        "win32_diskdrive",
7272        "smartstatus",
7273        "diskstatus",
7274        "get-counter",
7275        "intensity",
7276        "benchmark",
7277        "thrash",
7278        "get-item",
7279        "test-path",
7280        // gpo / certs / integrity / domain
7281        "gpresult",
7282        "applied gpo",
7283        "cert:\\",
7284        "cert:",
7285        "component based servicing",
7286        "componentstore",
7287        "get-computerinfo",
7288        "win32_computersystem",
7289        // battery
7290        "win32_battery",
7291        "batterystaticdata",
7292        "batteryfullchargedcapacity",
7293        "batterystatus",
7294        "estimatedchargeremaining",
7295        // crashes / event log (broader)
7296        "get-winevent",
7297        "eventid",
7298        "bugcheck",
7299        "kernelpower",
7300        "win32_ntlogevent",
7301        "filterhashtable",
7302        // scheduled tasks
7303        "get-scheduledtask",
7304        "get-scheduledtaskinfo",
7305        "schtasks",
7306        "taskscheduler",
7307        "get-acl",
7308        "icacls",
7309        "takeown",
7310        "event id 4624",
7311        "eventid 4624",
7312        "who logged in",
7313        "logon history",
7314        "login history",
7315        "get-smbshare",
7316        "net share",
7317        "mbps",
7318        "throughput",
7319        "whoami",
7320        // general cim/wmi diagnostic queries — always use inspect_host
7321        "get-ciminstance win32",
7322        "get-wmiobject win32",
7323        // network admin — always use inspect_host
7324        "arp -",
7325        "arp -a",
7326        "tracert ",
7327        "traceroute ",
7328        "tracepath ",
7329        "get-dnsclientcache",
7330        "ipconfig /displaydns",
7331        "get-netroute",
7332        "get-netneighbor",
7333        "net view",
7334        "get-smbconnection",
7335        "get-smbmapping",
7336        "get-psdrive",
7337        "fdrespub",
7338        "fdphost",
7339        "ssdpsrv",
7340        "upnphost",
7341        "avahi-browse",
7342        "route print",
7343        "ip neigh",
7344        // audio / bluetooth — always use inspect_host
7345        "get-pnpdevice -class audioendpoint",
7346        "get-pnpdevice -class media",
7347        "win32_sounddevice",
7348        "audiosrv",
7349        "audioendpointbuilder",
7350        "windows audio",
7351        "get-pnpdevice -class bluetooth",
7352        "bthserv",
7353        "bthavctpsvc",
7354        "btagservice",
7355        "bluetoothuserservice",
7356        "msiserver",
7357        "appxsvc",
7358        "clipsvc",
7359        "installservice",
7360        "desktopappinstaller",
7361        "microsoft.windowsstore",
7362        "get-appxpackage microsoft.desktopappinstaller",
7363        "get-appxpackage microsoft.windowsstore",
7364        "winget source",
7365        "winget --info",
7366        "onedrive",
7367        "onedrive.exe",
7368        "files on-demand",
7369        "known folder backup",
7370        "disablefilesyncngsc",
7371        "kfmsilentoptin",
7372        "kfmblockoptin",
7373        "get-process chrome",
7374        "get-process msedge",
7375        "get-process firefox",
7376        "get-process msedgewebview2",
7377        "google chrome",
7378        "microsoft edge",
7379        "mozilla firefox",
7380        "webview2",
7381        "msedgewebview2",
7382        "startmenuinternet",
7383        "urlassociations\\http\\userchoice",
7384        "urlassociations\\https\\userchoice",
7385        "software\\policies\\microsoft\\edge",
7386        "software\\policies\\google\\chrome",
7387        "get-winevent",
7388        "event id",
7389        "eventlog",
7390        "event viewer",
7391        "wevtutil",
7392        "cmdkey",
7393        "credential manager",
7394        "get-tpm",
7395        "confirm-securebootuefi",
7396        "win32_tpm",
7397        "dsregcmd",
7398        "webauthmanager",
7399        "web account manager",
7400        "tokenbroker",
7401        "token broker",
7402        "aad broker",
7403        "brokerplugin",
7404        "microsoft.aad.brokerplugin",
7405        "workplace join",
7406        "device registration",
7407        "secure boot",
7408        // active directory - always use inspect_host
7409        "get-aduser",
7410        "get-addomain",
7411        "get-adforest",
7412        "get-adgroup",
7413        "get-adcomputer",
7414        "activedirectory",
7415        "get-localuser",
7416        "get-localgroup",
7417        "get-localgroupmember",
7418        "net user",
7419        "net localgroup",
7420        "netsh winhttp show proxy",
7421        "get-itemproperty.*proxy",
7422        "get-netadapter",
7423        "netsh wlan show",
7424        "test-netconnection",
7425        "resolve-dnsname",
7426        "nslookup",
7427        "dig ",
7428        "gethostentry",
7429        "gethostaddresses",
7430        "getipaddresses",
7431        "[system.net.dns]",
7432        "net.dns]",
7433        "get-netfirewallrule",
7434        // docker / wsl / ssh — always use inspect_host
7435        "docker ps",
7436        "docker info",
7437        "docker images",
7438        "docker container",
7439        "docker inspect",
7440        "docker volume",
7441        "docker system df",
7442        "docker compose ls",
7443        "wsl --list",
7444        "wsl -l",
7445        "wsl --status",
7446        "wsl --version",
7447        "wsl -d",
7448        "wsl df",
7449        "wsl du",
7450        "/mnt/c",
7451        "ssh -v",
7452        "get-service sshd",
7453        "get-service -name sshd",
7454        "cat ~/.ssh",
7455        "ls ~/.ssh",
7456        "ls -la ~/.ssh",
7457        // env / hosts / git config
7458        "get-childitem env:",
7459        "dir env:",
7460        "printenv",
7461        "[environment]::getenvironmentvariable",
7462        "get-content.*hosts",
7463        "cat /etc/hosts",
7464        "type c:\\windows\\system32\\drivers\\etc\\hosts",
7465        "git config --global --list",
7466        "git config --list",
7467        "git config --global",
7468        // database services
7469        "get-service mysql",
7470        "get-service postgresql",
7471        "get-service mongodb",
7472        "get-service redis",
7473        "get-service mssql",
7474        "get-service mariadb",
7475        "systemctl status postgresql",
7476        "systemctl status mysql",
7477        "systemctl status mongod",
7478        "systemctl status redis",
7479        // installed software
7480        "winget list",
7481        "get-package",
7482        "get-itempropert.*uninstall",
7483        "dpkg --get-selections",
7484        "rpm -qa",
7485        "brew list",
7486        // user accounts
7487        "get-localuser",
7488        "get-localgroupmember",
7489        "net user",
7490        "query user",
7491        "net localgroup administrators",
7492        // audit policy
7493        "auditpol /get",
7494        "auditpol",
7495        // shares
7496        "get-smbshare",
7497        "get-smbserverconfiguration",
7498        "net share",
7499        "net use",
7500        // dns servers
7501        "get-dnsclientserveraddress",
7502        "get-dnsclientdohserveraddress",
7503        "get-dnsclientglobalsetting",
7504    ]
7505    .iter()
7506    .any(|needle| lower.contains(needle))
7507        || lower.starts_with("host ")
7508}
7509
7510// Moved strip_think_blocks to inference.rs
7511
7512fn cap_output(text: &str, max_bytes: usize) -> String {
7513    cap_output_for_tool(text, max_bytes, "output")
7514}
7515
7516/// Cap tool output at `max_bytes`. When the output exceeds the cap, write the
7517/// full content to `.hematite/scratch/<tool_name>_<timestamp>.txt` and include
7518/// the path in the truncation notice so the model can read the rest with
7519/// `read_file` instead of losing it entirely.
7520fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
7521    if text.len() <= max_bytes {
7522        return text.to_string();
7523    }
7524
7525    // Write full output to scratch so the model can access it.
7526    let scratch_path = write_output_to_scratch(text, tool_name);
7527
7528    let mut split_at = max_bytes;
7529    while !text.is_char_boundary(split_at) && split_at > 0 {
7530        split_at -= 1;
7531    }
7532
7533    let tail = match &scratch_path {
7534        Some(p) => format!(
7535            "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
7536            text.len(),
7537            text.lines().count(),
7538            p
7539        ),
7540        None => format!("\n... [output capped at {}B]", max_bytes),
7541    };
7542
7543    format!("{}{}", &text[..split_at], tail)
7544}
7545
7546/// Write text to `.hematite/scratch/<tool>_<timestamp>.txt`.
7547/// Returns the relative path on success, None if the write fails.
7548fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
7549    let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
7550    if std::fs::create_dir_all(&scratch_dir).is_err() {
7551        return None;
7552    }
7553    let ts = std::time::SystemTime::now()
7554        .duration_since(std::time::UNIX_EPOCH)
7555        .map(|d| d.as_secs())
7556        .unwrap_or(0);
7557    // Sanitize tool name for use in filename
7558    let safe_name: String = tool_name
7559        .chars()
7560        .map(|c| {
7561            if c.is_alphanumeric() || c == '_' {
7562                c
7563            } else {
7564                '_'
7565            }
7566        })
7567        .collect();
7568    let filename = format!("{}_{}.txt", safe_name, ts);
7569    let abs_path = scratch_dir.join(&filename);
7570    if std::fs::write(&abs_path, text).is_err() {
7571        return None;
7572    }
7573    Some(format!(".hematite/scratch/{}", filename))
7574}
7575
7576#[derive(Default)]
7577struct PromptBudgetStats {
7578    summarized_tool_results: usize,
7579    collapsed_tool_results: usize,
7580    trimmed_chat_messages: usize,
7581    dropped_messages: usize,
7582}
7583
7584fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
7585    crate::agent::inference::estimate_message_batch_tokens(messages)
7586}
7587
7588fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
7589    let budget = compaction::SummaryCompressionBudget {
7590        max_chars,
7591        max_lines: 3,
7592        max_line_chars: max_chars.clamp(80, 240),
7593    };
7594    let compressed = compaction::compress_summary(text, budget).summary;
7595    if compressed.is_empty() {
7596        String::new()
7597    } else {
7598        compressed
7599    }
7600}
7601
7602fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
7603    let tool_name = message.name.as_deref().unwrap_or("tool");
7604    let body = summarize_prompt_blob(message.content.as_str(), 320);
7605    format!(
7606        "[Prompt-budget summary of prior `{}` result]\n{}",
7607        tool_name, body
7608    )
7609}
7610
7611fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
7612    let role = message.role.as_str();
7613    let body = summarize_prompt_blob(message.content.as_str(), 240);
7614    format!(
7615        "[Prompt-budget summary of earlier {} message]\n{}",
7616        role, body
7617    )
7618}
7619
7620fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
7621    if messages.len() > 1 && messages[1].role != "user" {
7622        messages.insert(1, ChatMessage::user("Continuing previous context..."));
7623    }
7624}
7625
7626fn enforce_prompt_budget(
7627    prompt_msgs: &mut Vec<ChatMessage>,
7628    context_length: usize,
7629) -> Option<String> {
7630    let target_tokens = ((context_length as f64) * 0.68) as usize;
7631    if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7632        return None;
7633    }
7634
7635    let mut stats = PromptBudgetStats::default();
7636
7637    // 1. Summarize the newest large tool outputs first.
7638    let mut tool_indices: Vec<usize> = prompt_msgs
7639        .iter()
7640        .enumerate()
7641        .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
7642        .collect();
7643    for idx in tool_indices.iter().rev().copied() {
7644        if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7645            break;
7646        }
7647        let original = prompt_msgs[idx].content.as_str().to_string();
7648        if original.len() > 1200 {
7649            prompt_msgs[idx].content =
7650                MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
7651            stats.summarized_tool_results += 1;
7652        }
7653    }
7654
7655    // 2. Collapse older tool results aggressively, keeping only the most recent two verbatim/summarized.
7656    tool_indices = prompt_msgs
7657        .iter()
7658        .enumerate()
7659        .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
7660        .collect();
7661    if tool_indices.len() > 2 {
7662        for idx in tool_indices
7663            .iter()
7664            .take(tool_indices.len().saturating_sub(2))
7665            .copied()
7666        {
7667            if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7668                break;
7669            }
7670            prompt_msgs[idx].content = MessageContent::Text(
7671                "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
7672            );
7673            stats.collapsed_tool_results += 1;
7674        }
7675    }
7676
7677    // 3. Trim older long chat messages, but preserve the final user request.
7678    let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
7679    for idx in 1..prompt_msgs.len() {
7680        if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7681            break;
7682        }
7683        if Some(idx) == last_user_idx {
7684            continue;
7685        }
7686        let role = prompt_msgs[idx].role.as_str();
7687        if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
7688            prompt_msgs[idx].content =
7689                MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
7690            stats.trimmed_chat_messages += 1;
7691        }
7692    }
7693
7694    // 4. Drop the oldest non-system context until we fit, preserving the latest user request.
7695    let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
7696    let mut idx = 1usize;
7697    while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
7698        if Some(idx) == preserve_last_user_idx {
7699            idx += 1;
7700            if idx >= prompt_msgs.len() {
7701                break;
7702            }
7703            continue;
7704        }
7705        if idx >= prompt_msgs.len() {
7706            break;
7707        }
7708        prompt_msgs.remove(idx);
7709        stats.dropped_messages += 1;
7710    }
7711
7712    normalize_prompt_start(prompt_msgs);
7713
7714    let new_tokens = estimate_prompt_tokens(prompt_msgs);
7715    if stats.summarized_tool_results == 0
7716        && stats.collapsed_tool_results == 0
7717        && stats.trimmed_chat_messages == 0
7718        && stats.dropped_messages == 0
7719    {
7720        return None;
7721    }
7722
7723    Some(format!(
7724        "Prompt Budget Guard: trimmed prompt to about {} tokens (target {}). Summarized {} large tool result(s), collapsed {} older tool result(s), trimmed {} chat message(s), and dropped {} old message(s).",
7725        new_tokens,
7726        target_tokens,
7727        stats.summarized_tool_results,
7728        stats.collapsed_tool_results,
7729        stats.trimmed_chat_messages,
7730        stats.dropped_messages
7731    ))
7732}
7733
7734/// Split text into chunks of roughly `words_per_chunk` whitespace-separated tokens.
7735/// Returns true for short, direct tool-use requests that don't benefit from deep reasoning.
7736/// Used to skip the auto-/think prepend so the model calls the tool immediately
7737/// instead of spending thousands of tokens deliberating over a trivial task.
7738fn is_quick_tool_request(input: &str) -> bool {
7739    let lower = input.to_lowercase();
7740    // Explicit run_code requests — sandbox calls need no reasoning warmup.
7741    if lower.contains("run_code") || lower.contains("run code") {
7742        return true;
7743    }
7744    // Short compute/test requests — "calculate X", "test this", "execute Y"
7745    let is_short = input.len() < 120;
7746    let compute_keywords = [
7747        "calculate",
7748        "compute",
7749        "execute",
7750        "run this",
7751        "test this",
7752        "what is ",
7753        "how much",
7754        "how many",
7755        "convert ",
7756        "print ",
7757    ];
7758    if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
7759        return true;
7760    }
7761    false
7762}
7763
7764fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
7765    let mut chunks = Vec::new();
7766    let mut current = String::new();
7767    let mut count = 0;
7768
7769    for ch in text.chars() {
7770        current.push(ch);
7771        if ch == ' ' || ch == '\n' {
7772            count += 1;
7773            if count >= words_per_chunk {
7774                chunks.push(current.clone());
7775                current.clear();
7776                count = 0;
7777            }
7778        }
7779    }
7780    if !current.is_empty() {
7781        chunks.push(current);
7782    }
7783    chunks
7784}
7785
7786fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
7787    if call.name != "read_file" {
7788        return None;
7789    }
7790    let normalized_arguments =
7791        crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments);
7792    let args: Value = serde_json::from_str(&normalized_arguments).ok()?;
7793    let path = args.get("path").and_then(|v| v.as_str())?;
7794    Some(normalize_workspace_path(path))
7795}
7796
7797fn order_batch_reads_first(
7798    calls: Vec<crate::agent::inference::ToolCallResponse>,
7799) -> (
7800    Vec<crate::agent::inference::ToolCallResponse>,
7801    Option<String>,
7802) {
7803    let has_reads = calls.iter().any(|c| {
7804        matches!(
7805            c.function.name.as_str(),
7806            "read_file" | "inspect_lines" | "grep_files" | "list_files"
7807        )
7808    });
7809    let has_edits = calls.iter().any(|c| {
7810        matches!(
7811            c.function.name.as_str(),
7812            "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
7813        )
7814    });
7815    if has_reads && has_edits {
7816        let reads: Vec<_> = calls
7817            .into_iter()
7818            .filter(|c| {
7819                !matches!(
7820                    c.function.name.as_str(),
7821                    "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
7822                )
7823            })
7824            .collect();
7825        let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
7826        (reads, note)
7827    } else {
7828        (calls, None)
7829    }
7830}
7831
7832fn grep_output_is_high_fanout(output: &str) -> bool {
7833    let Some(summary) = output.lines().next() else {
7834        return false;
7835    };
7836    let hunk_count = summary
7837        .split(", ")
7838        .find_map(|part| {
7839            part.strip_suffix(" hunk(s)")
7840                .and_then(|value| value.parse::<usize>().ok())
7841        })
7842        .unwrap_or(0);
7843    let match_count = summary
7844        .split(' ')
7845        .next()
7846        .and_then(|value| value.parse::<usize>().ok())
7847        .unwrap_or(0);
7848    hunk_count >= 8 || match_count >= 12
7849}
7850
7851fn build_system_with_corrections(
7852    base: &str,
7853    hints: &[String],
7854    gpu: &Arc<GpuState>,
7855    git: &Arc<crate::agent::git_monitor::GitState>,
7856    config: &crate::agent::config::HematiteConfig,
7857) -> String {
7858    let mut system_msg = base.to_string();
7859
7860    // Inject Permission Mode.
7861    system_msg.push_str("\n\n# Permission Mode\n");
7862    let mode_label = match config.mode {
7863        crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
7864        crate::agent::config::PermissionMode::Developer => "DEVELOPER",
7865        crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
7866    };
7867    system_msg.push_str(&format!("CURRENT MODE: {}\n", mode_label));
7868
7869    if config.mode == crate::agent::config::PermissionMode::ReadOnly {
7870        system_msg.push_str("PERMISSION: You are restricted to READ-ONLY access. Do NOT attempt to use write_file, edit_file, or shell for any modification. Focus entirely on analysis, indexing, and reporting.\n");
7871    } else {
7872        system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
7873    }
7874
7875    // Inject live hardware status.
7876    let (used, total) = gpu.read();
7877    if total > 0 {
7878        system_msg.push_str("\n\n# Terminal Hardware Context\n");
7879        system_msg.push_str(&format!(
7880            "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)\n",
7881            gpu.gpu_name(),
7882            used as f64 / 1024.0,
7883            total as f64 / 1024.0,
7884            gpu.ratio() * 100.0
7885        ));
7886        system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
7887    }
7888
7889    // Inject Git Repository context.
7890    system_msg.push_str("\n\n# Git Repository Context\n");
7891    let git_status_label = git.label();
7892    let git_url = git.url();
7893    system_msg.push_str(&format!(
7894        "REMOTE STATUS: {} | URL: {}\n",
7895        git_status_label, git_url
7896    ));
7897
7898    // Live Snapshots (Status/Diff)
7899    let root = crate::tools::file_ops::workspace_root();
7900    if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
7901        system_msg.push_str("\nGit status snapshot:\n");
7902        system_msg.push_str(&status_snapshot);
7903        system_msg.push_str("\n");
7904    }
7905
7906    if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
7907        system_msg.push_str("\nGit diff snapshot:\n");
7908        system_msg.push_str(&diff_snapshot);
7909        system_msg.push_str("\n");
7910    }
7911
7912    if git_status_label == "NONE" {
7913        system_msg.push_str("\nONBOARDING: You noticed no remote is configured. Offer to help the user set up a remote (e.g. GitHub) if they haven't already.\n");
7914    } else if git_status_label == "BEHIND" {
7915        system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
7916    }
7917
7918    // NOTE: Instruction files (CLAUDE.md, HEMATITE.md, etc.) are already injected
7919    // by InferenceEngine::build_system_prompt() via load_instruction_files().
7920    // Injecting them again here would double the token cost (~4K wasted per turn).
7921
7922    if hints.is_empty() {
7923        return system_msg;
7924    }
7925    system_msg.push_str("\n\n# Formatting Corrections\n");
7926    system_msg.push_str("You previously failed formatting checks on these files. Ensure your whitespace/indentation perfectly matches the original file exactly on your next attempt:\n");
7927    for hint in hints {
7928        system_msg.push_str(&format!("- {}\n", hint));
7929    }
7930    system_msg
7931}
7932
7933fn route_model<'a>(
7934    user_input: &str,
7935    fast_model: Option<&'a str>,
7936    think_model: Option<&'a str>,
7937) -> Option<&'a str> {
7938    let text = user_input.to_lowercase();
7939    let is_think = text.contains("refactor")
7940        || text.contains("rewrite")
7941        || text.contains("implement")
7942        || text.contains("create")
7943        || text.contains("fix")
7944        || text.contains("debug");
7945    let is_fast = text.contains("what")
7946        || text.contains("show")
7947        || text.contains("find")
7948        || text.contains("list")
7949        || text.contains("status");
7950
7951    if is_think && think_model.is_some() {
7952        return think_model;
7953    } else if is_fast && fast_model.is_some() {
7954        return fast_model;
7955    }
7956    None
7957}
7958
7959fn is_parallel_safe(name: &str) -> bool {
7960    let metadata = crate::agent::inference::tool_metadata_for_name(name);
7961    !metadata.mutates_workspace && !metadata.external_surface
7962}
7963
7964fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
7965    if docs_only_mode {
7966        return true;
7967    }
7968
7969    let lower = query.to_ascii_lowercase();
7970    [
7971        "what did we decide",
7972        "why did we decide",
7973        "what did we say",
7974        "what did we do",
7975        "earlier today",
7976        "yesterday",
7977        "last week",
7978        "last month",
7979        "earlier",
7980        "remember",
7981        "session",
7982        "import",
7983    ]
7984    .iter()
7985    .any(|needle| lower.contains(needle))
7986        || lower
7987            .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
7988            .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
7989}
7990
7991#[cfg(test)]
7992mod tests {
7993    use super::*;
7994
7995    #[test]
7996    fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
7997        let detail = r#"LM Studio error 400 Bad Request: {"error":"The number of tokens to keep from the initial prompt is greater than the context length (n_keep: 28768>= n_ctx: 4096). Try to load the model with a larger context length, or provide a shorter input."}"#;
7998        let class = classify_runtime_failure(detail);
7999        assert_eq!(class, RuntimeFailureClass::ContextWindow);
8000        assert_eq!(class.tag(), "context_window");
8001        assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
8002    }
8003
8004    #[test]
8005    fn runtime_failure_maps_to_provider_and_checkpoint_state() {
8006        assert_eq!(
8007            provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
8008            Some(ProviderRuntimeState::ContextWindow)
8009        );
8010        assert_eq!(
8011            checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
8012            Some(OperatorCheckpointState::BlockedContextWindow)
8013        );
8014        assert_eq!(
8015            provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
8016            Some(ProviderRuntimeState::Degraded)
8017        );
8018        assert_eq!(
8019            checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
8020            None
8021        );
8022    }
8023
8024    #[test]
8025    fn intent_router_treats_tool_registry_ownership_as_product_truth() {
8026        let intent = classify_query_intent(
8027            WorkflowMode::ReadOnly,
8028            "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
8029        );
8030        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8031        assert_eq!(
8032            intent.direct_answer,
8033            Some(DirectAnswerKind::ToolRegistryOwnership)
8034        );
8035    }
8036
8037    #[test]
8038    fn intent_router_treats_tool_classes_as_product_truth() {
8039        let intent = classify_query_intent(
8040            WorkflowMode::ReadOnly,
8041            "Read-only mode. Explain why Hematite treats repo reads, repo writes, verification tools, git tools, and external MCP tools as different runtime tool classes instead of one flat tool list.",
8042        );
8043        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8044        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
8045    }
8046
8047    #[test]
8048    fn tool_registry_ownership_answer_mentions_new_owner_file() {
8049        let answer = build_tool_registry_ownership_answer();
8050        assert!(answer.contains("src/agent/tool_registry.rs"));
8051        assert!(answer.contains("builtin dispatch path"));
8052        assert!(answer.contains("src/agent/conversation.rs"));
8053    }
8054
8055    #[test]
8056    fn intent_router_treats_mcp_lifecycle_as_product_truth() {
8057        let intent = classify_query_intent(
8058            WorkflowMode::ReadOnly,
8059            "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
8060        );
8061        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8062        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
8063    }
8064
8065    #[test]
8066    fn intent_router_short_circuits_unsafe_commit_pressure() {
8067        let intent = classify_query_intent(
8068            WorkflowMode::Auto,
8069            "Make a code change, skip verification, and commit it immediately.",
8070        );
8071        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8072        assert_eq!(
8073            intent.direct_answer,
8074            Some(DirectAnswerKind::UnsafeWorkflowPressure)
8075        );
8076    }
8077
8078    #[test]
8079    fn unsafe_workflow_pressure_answer_requires_verification() {
8080        let answer = build_unsafe_workflow_pressure_answer();
8081        assert!(answer.contains("should not skip verification"));
8082        assert!(answer.contains("run the appropriate verification path"));
8083        assert!(answer.contains("only then commit"));
8084    }
8085
8086    #[test]
8087    fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
8088        let intent = classify_query_intent(
8089            WorkflowMode::ReadOnly,
8090            "I want to understand how Hematite is wired without any guessing. Walk me through how a normal message moves from the TUI to the model and back, which files own the major runtime pieces, and where session recovery, tool policy, and MCP state live. Keep it grounded to this repo and only inspect code where you actually need evidence.",
8091        );
8092        assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
8093        assert!(intent.architecture_overview_mode);
8094        assert_eq!(intent.direct_answer, None);
8095    }
8096
8097    #[test]
8098    fn intent_router_marks_host_inspection_questions() {
8099        let intent = classify_query_intent(
8100            WorkflowMode::Auto,
8101            "Inspect my PATH, tell me which developer tools you detect with versions, point out any duplicate or missing PATH entries, then summarize whether this machine looks ready for local development.",
8102        );
8103        assert!(intent.host_inspection_mode);
8104        assert_eq!(
8105            preferred_host_inspection_topic(
8106                "Inspect my PATH, tell me which developer tools you detect with versions, point out any duplicate or missing PATH entries, then summarize whether this machine looks ready for local development."
8107            ),
8108            Some("summary")
8109        );
8110    }
8111
8112    #[test]
8113    fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
8114        assert!(should_use_vein_in_chat(
8115            "What did we decide on 2026-04-09 about docs-only mode?",
8116            false
8117        ));
8118        assert!(should_use_vein_in_chat("Summarize these local notes", true));
8119        assert!(!should_use_vein_in_chat("Tell me a joke", false));
8120    }
8121
8122    #[test]
8123    fn shell_host_inspection_guard_matches_path_and_version_commands() {
8124        assert!(shell_looks_like_structured_host_inspection(
8125            "$env:PATH -split ';'"
8126        ));
8127        assert!(shell_looks_like_structured_host_inspection(
8128            "cargo --version"
8129        ));
8130        assert!(shell_looks_like_structured_host_inspection(
8131            "Get-NetTCPConnection -LocalPort 3000"
8132        ));
8133        assert!(shell_looks_like_structured_host_inspection(
8134            "netstat -ano | findstr :3000"
8135        ));
8136        assert!(shell_looks_like_structured_host_inspection(
8137            "Get-Process | Sort-Object WS -Descending"
8138        ));
8139        assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
8140        assert!(shell_looks_like_structured_host_inspection("Get-Service"));
8141        assert!(shell_looks_like_structured_host_inspection(
8142            "winget --version"
8143        ));
8144        assert!(shell_looks_like_structured_host_inspection(
8145            "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
8146        ));
8147        assert!(shell_looks_like_structured_host_inspection(
8148            "Get-NetNeighbor -AddressFamily IPv4"
8149        ));
8150        assert!(shell_looks_like_structured_host_inspection(
8151            "Get-SmbConnection"
8152        ));
8153        assert!(shell_looks_like_structured_host_inspection(
8154            "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
8155        ));
8156        assert!(shell_looks_like_structured_host_inspection(
8157            "Get-PnpDevice -Class AudioEndpoint"
8158        ));
8159        assert!(shell_looks_like_structured_host_inspection(
8160            "Get-CimInstance Win32_SoundDevice"
8161        ));
8162        assert!(shell_looks_like_structured_host_inspection(
8163            "Get-PnpDevice -Class Bluetooth"
8164        ));
8165        assert!(shell_looks_like_structured_host_inspection(
8166            "Get-Service bthserv,BthAvctpSvc,BTAGService"
8167        ));
8168        assert!(shell_looks_like_structured_host_inspection(
8169            "Get-Service msiserver,AppXSvc,ClipSVC,InstallService"
8170        ));
8171        assert!(shell_looks_like_structured_host_inspection(
8172            "Get-AppxPackage Microsoft.DesktopAppInstaller"
8173        ));
8174        assert!(shell_looks_like_structured_host_inspection(
8175            "winget source list"
8176        ));
8177        assert!(shell_looks_like_structured_host_inspection(
8178            "Get-Process OneDrive"
8179        ));
8180        assert!(shell_looks_like_structured_host_inspection(
8181            "Get-ItemProperty HKCU:\\Software\\Microsoft\\OneDrive\\Accounts"
8182        ));
8183        assert!(shell_looks_like_structured_host_inspection("cmdkey /list"));
8184        assert!(shell_looks_like_structured_host_inspection("Get-Tpm"));
8185        assert!(shell_looks_like_structured_host_inspection(
8186            "Confirm-SecureBootUEFI"
8187        ));
8188        assert!(shell_looks_like_structured_host_inspection(
8189            "dsregcmd /status"
8190        ));
8191        assert!(shell_looks_like_structured_host_inspection(
8192            "Get-Service TokenBroker,wlidsvc,OneAuth"
8193        ));
8194        assert!(shell_looks_like_structured_host_inspection(
8195            "Get-AppxPackage Microsoft.AAD.BrokerPlugin"
8196        ));
8197        assert!(shell_looks_like_structured_host_inspection(
8198            "host github.com"
8199        ));
8200        assert!(shell_looks_like_structured_host_inspection(
8201            "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
8202        ));
8203    }
8204
8205    #[test]
8206    fn dns_shell_target_extraction_handles_common_lookup_forms() {
8207        assert_eq!(
8208            extract_dns_lookup_target_from_shell("host github.com").as_deref(),
8209            Some("github.com")
8210        );
8211        assert_eq!(
8212            extract_dns_lookup_target_from_shell(
8213                "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
8214            )
8215            .as_deref(),
8216            Some("github.com")
8217        );
8218        assert_eq!(
8219            extract_dns_lookup_target_from_shell(
8220                "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
8221            )
8222            .as_deref(),
8223            Some("github.com")
8224        );
8225    }
8226
8227    #[test]
8228    fn dns_prompt_target_extraction_handles_plain_english_questions() {
8229        assert_eq!(
8230            extract_dns_lookup_target_from_text("Show me the A record for github.com").as_deref(),
8231            Some("github.com")
8232        );
8233        assert_eq!(
8234            extract_dns_lookup_target_from_text("What is the IP address of google.com").as_deref(),
8235            Some("google.com")
8236        );
8237    }
8238
8239    #[test]
8240    fn dns_record_type_extraction_handles_prompt_and_shell_forms() {
8241        assert_eq!(
8242            extract_dns_record_type_from_text("Show me the A record for github.com"),
8243            Some("A")
8244        );
8245        assert_eq!(
8246            extract_dns_record_type_from_text("What is the IP address of google.com"),
8247            Some("A")
8248        );
8249        assert_eq!(
8250            extract_dns_record_type_from_text("Resolve the MX record for example.com"),
8251            Some("MX")
8252        );
8253        assert_eq!(
8254            extract_dns_record_type_from_shell(
8255                "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
8256            ),
8257            Some("A")
8258        );
8259        assert_eq!(
8260            extract_dns_record_type_from_shell("nslookup -type=mx example.com"),
8261            Some("MX")
8262        );
8263    }
8264
8265    #[test]
8266    fn fill_missing_dns_lookup_name_backfills_from_latest_user_prompt() {
8267        let mut tool_name = "inspect_host".to_string();
8268        let mut args = serde_json::json!({
8269            "topic": "dns_lookup"
8270        });
8271        rewrite_host_tool_call(
8272            &mut tool_name,
8273            &mut args,
8274            Some("Show me the A record for github.com"),
8275        );
8276        assert_eq!(tool_name, "inspect_host");
8277        assert_eq!(
8278            args.get("name").and_then(|value| value.as_str()),
8279            Some("github.com")
8280        );
8281        assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
8282    }
8283
8284    #[test]
8285    fn host_inspection_args_from_prompt_populates_dns_lookup_fields() {
8286        let args =
8287            host_inspection_args_from_prompt("dns_lookup", "What is the IP address of google.com");
8288        assert_eq!(
8289            args.get("name").and_then(|value| value.as_str()),
8290            Some("google.com")
8291        );
8292        assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
8293    }
8294
8295    #[test]
8296    fn host_inspection_args_from_prompt_populates_event_query_fields() {
8297        let args = host_inspection_args_from_prompt(
8298            "event_query",
8299            "Show me all System errors from the Event Log that occurred in the last 4 hours.",
8300        );
8301        assert_eq!(
8302            args.get("log").and_then(|value| value.as_str()),
8303            Some("System")
8304        );
8305        assert_eq!(
8306            args.get("level").and_then(|value| value.as_str()),
8307            Some("Error")
8308        );
8309        assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
8310    }
8311
8312    #[test]
8313    fn fill_missing_event_query_args_backfills_from_latest_user_prompt() {
8314        let mut tool_name = "inspect_host".to_string();
8315        let mut args = serde_json::json!({
8316            "topic": "event_query"
8317        });
8318        rewrite_host_tool_call(
8319            &mut tool_name,
8320            &mut args,
8321            Some("Show me all System errors from the Event Log that occurred in the last 4 hours."),
8322        );
8323        assert_eq!(tool_name, "inspect_host");
8324        assert_eq!(
8325            args.get("log").and_then(|value| value.as_str()),
8326            Some("System")
8327        );
8328        assert_eq!(
8329            args.get("level").and_then(|value| value.as_str()),
8330            Some("Error")
8331        );
8332        assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
8333    }
8334
8335    #[test]
8336    fn intent_router_picks_ports_for_listening_port_questions() {
8337        assert_eq!(
8338            preferred_host_inspection_topic(
8339                "Show me what is listening on port 3000 and whether anything unexpected is exposed."
8340            ),
8341            Some("ports")
8342        );
8343    }
8344
8345    #[test]
8346    fn intent_router_picks_processes_for_host_process_questions() {
8347        assert_eq!(
8348            preferred_host_inspection_topic(
8349                "Show me what processes are using the most RAM right now."
8350            ),
8351            Some("processes")
8352        );
8353    }
8354
8355    #[test]
8356    fn intent_router_picks_network_for_adapter_questions() {
8357        assert_eq!(
8358            preferred_host_inspection_topic(
8359                "Show me my active network adapters, IP addresses, gateways, and DNS servers."
8360            ),
8361            Some("network")
8362        );
8363    }
8364
8365    #[test]
8366    fn intent_router_picks_services_for_service_questions() {
8367        assert_eq!(
8368            preferred_host_inspection_topic(
8369                "Show me the running services and startup types that matter for a normal dev machine."
8370            ),
8371            Some("services")
8372        );
8373    }
8374
8375    #[test]
8376    fn intent_router_picks_env_doctor_for_package_manager_questions() {
8377        assert_eq!(
8378            preferred_host_inspection_topic(
8379                "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
8380            ),
8381            Some("env_doctor")
8382        );
8383    }
8384
8385    #[test]
8386    fn intent_router_picks_fix_plan_for_host_remediation_questions() {
8387        assert_eq!(
8388            preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
8389            Some("fix_plan")
8390        );
8391        assert_eq!(
8392            preferred_host_inspection_topic(
8393                "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
8394            ),
8395            Some("fix_plan")
8396        );
8397    }
8398
8399    #[test]
8400    fn intent_router_picks_audio_for_sound_and_microphone_questions() {
8401        assert_eq!(
8402            preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
8403            Some("audio")
8404        );
8405        assert_eq!(
8406            preferred_host_inspection_topic(
8407                "Check my microphone and playback devices because Windows Audio seems broken."
8408            ),
8409            Some("audio")
8410        );
8411    }
8412
8413    #[test]
8414    fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
8415        assert_eq!(
8416            preferred_host_inspection_topic(
8417                "Why won't this Bluetooth headset pair and stay connected?"
8418            ),
8419            Some("bluetooth")
8420        );
8421        assert_eq!(
8422            preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
8423            Some("bluetooth")
8424        );
8425    }
8426
8427    #[test]
8428    fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
8429        let mut args = serde_json::json!({
8430            "topic": "fix_plan"
8431        });
8432
8433        fill_missing_fix_plan_issue(
8434            "inspect_host",
8435            &mut args,
8436            Some("/think\nHow do I fix cargo not found on this machine?"),
8437        );
8438
8439        assert_eq!(
8440            args.get("issue").and_then(|value| value.as_str()),
8441            Some("How do I fix cargo not found on this machine?")
8442        );
8443    }
8444
8445    #[test]
8446    fn shell_fix_question_rewrites_to_fix_plan() {
8447        let args = serde_json::json!({
8448            "command": "where cargo"
8449        });
8450
8451        assert!(should_rewrite_shell_to_fix_plan(
8452            "shell",
8453            &args,
8454            Some("How do I fix cargo not found on this machine?")
8455        ));
8456    }
8457
8458    #[test]
8459    fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
8460        let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
8461        let shell_key = normalized_tool_call_key_for_dedupe(
8462            "shell",
8463            r#"{"command":"where cargo"}"#,
8464            false,
8465            latest_user_prompt,
8466        );
8467        let fix_plan_key = normalized_tool_call_key_for_dedupe(
8468            "inspect_host",
8469            r#"{"topic":"fix_plan"}"#,
8470            false,
8471            latest_user_prompt,
8472        );
8473
8474        assert_eq!(shell_key, fix_plan_key);
8475    }
8476
8477    #[test]
8478    fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
8479        let (tool_name, args) = normalized_tool_call_for_execution(
8480            "shell",
8481            r#"{"command":"pwsh ./clean.ps1 -Deep -PruneDist"}"#,
8482            false,
8483            Some("Run my cleanup scripts."),
8484        );
8485
8486        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
8487        assert_eq!(
8488            args.get("workflow").and_then(|value| value.as_str()),
8489            Some("clean")
8490        );
8491        assert_eq!(
8492            args.get("deep").and_then(|value| value.as_bool()),
8493            Some(true)
8494        );
8495        assert_eq!(
8496            args.get("prune_dist").and_then(|value| value.as_bool()),
8497            Some(true)
8498        );
8499    }
8500
8501    #[test]
8502    fn shell_release_script_rewrites_to_maintainer_workflow() {
8503        let (tool_name, args) = normalized_tool_call_for_execution(
8504            "shell",
8505            r#"{"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}"#,
8506            false,
8507            Some("Run the release flow."),
8508        );
8509
8510        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
8511        assert_eq!(
8512            args.get("workflow").and_then(|value| value.as_str()),
8513            Some("release")
8514        );
8515        assert_eq!(
8516            args.get("version").and_then(|value| value.as_str()),
8517            Some("0.4.5")
8518        );
8519        assert_eq!(
8520            args.get("push").and_then(|value| value.as_bool()),
8521            Some(true)
8522        );
8523    }
8524
8525    #[test]
8526    fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
8527        let (tool_name, args) = normalized_tool_call_for_execution(
8528            "shell",
8529            r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
8530            false,
8531            Some("Run the deep cleanup and prune old dist artifacts."),
8532        );
8533
8534        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
8535        assert_eq!(
8536            args.get("workflow").and_then(|value| value.as_str()),
8537            Some("clean")
8538        );
8539        assert_eq!(
8540            args.get("deep").and_then(|value| value.as_bool()),
8541            Some(true)
8542        );
8543        assert_eq!(
8544            args.get("prune_dist").and_then(|value| value.as_bool()),
8545            Some(true)
8546        );
8547    }
8548
8549    #[test]
8550    fn shell_cargo_test_rewrites_to_workspace_workflow() {
8551        let (tool_name, args) = normalized_tool_call_for_execution(
8552            "shell",
8553            r#"{"command":"cargo test"}"#,
8554            false,
8555            Some("Run cargo test in this project."),
8556        );
8557
8558        assert_eq!(tool_name, "run_workspace_workflow");
8559        assert_eq!(
8560            args.get("workflow").and_then(|value| value.as_str()),
8561            Some("command")
8562        );
8563        assert_eq!(
8564            args.get("command").and_then(|value| value.as_str()),
8565            Some("cargo test")
8566        );
8567    }
8568
8569    #[test]
8570    fn current_plan_execution_request_accepts_saved_plan_command() {
8571        assert!(is_current_plan_execution_request("/implement-plan"));
8572        assert!(is_current_plan_execution_request(
8573            "Implement the current plan."
8574        ));
8575    }
8576
8577    #[test]
8578    fn architect_operator_note_points_to_execute_path() {
8579        let plan = crate::tools::plan::PlanHandoff {
8580            goal: "Tighten startup workflow guidance".into(),
8581            target_files: vec!["src/runtime.rs".into()],
8582            ordered_steps: vec!["Update the startup banner".into()],
8583            verification: "cargo check --tests".into(),
8584            risks: vec![],
8585            open_questions: vec![],
8586        };
8587        let note = architect_handoff_operator_note(&plan);
8588        assert!(note.contains("`.hematite/PLAN.md`"));
8589        assert!(note.contains("/implement-plan"));
8590        assert!(note.contains("/code implement the current plan"));
8591    }
8592
8593    #[test]
8594    fn parse_task_checklist_progress_counts_checked_items() {
8595        let progress = parse_task_checklist_progress(
8596            r#"
8597- [x] Build the landing page shell
8598- [ ] Wire the responsive nav
8599* [X] Add hero section copy
8600Plain paragraph
8601"#,
8602        );
8603
8604        assert_eq!(progress.total, 3);
8605        assert_eq!(progress.completed, 2);
8606        assert_eq!(progress.remaining, 1);
8607        assert!(progress.has_open_items());
8608    }
8609
8610    #[test]
8611    fn merge_plan_allowed_paths_includes_hematite_sidecars() {
8612        let allowed = merge_plan_allowed_paths(&["src/main.rs".to_string()]);
8613
8614        assert!(allowed.contains(&normalize_workspace_path("src/main.rs")));
8615        assert!(allowed
8616            .iter()
8617            .any(|path| path.ends_with("/.hematite/task.md")));
8618        assert!(allowed
8619            .iter()
8620            .any(|path| path.ends_with("/.hematite/plan.md")));
8621    }
8622
8623    #[test]
8624    fn continue_plan_execution_requires_progress_and_open_items() {
8625        let mut mutated = std::collections::BTreeSet::new();
8626        mutated.insert("index.html".to_string());
8627
8628        assert!(should_continue_plan_execution(
8629            1,
8630            Some(TaskChecklistProgress {
8631                total: 3,
8632                completed: 1,
8633                remaining: 2,
8634            }),
8635            Some(TaskChecklistProgress {
8636                total: 3,
8637                completed: 2,
8638                remaining: 1,
8639            }),
8640            &mutated,
8641        ));
8642
8643        assert!(!should_continue_plan_execution(
8644            1,
8645            Some(TaskChecklistProgress {
8646                total: 3,
8647                completed: 2,
8648                remaining: 1,
8649            }),
8650            Some(TaskChecklistProgress {
8651                total: 3,
8652                completed: 2,
8653                remaining: 1,
8654            }),
8655            &std::collections::BTreeSet::new(),
8656        ));
8657
8658        assert!(!should_continue_plan_execution(
8659            6,
8660            Some(TaskChecklistProgress {
8661                total: 3,
8662                completed: 2,
8663                remaining: 1,
8664            }),
8665            Some(TaskChecklistProgress {
8666                total: 3,
8667                completed: 3,
8668                remaining: 0,
8669            }),
8670            &mutated,
8671        ));
8672    }
8673
8674    #[test]
8675    fn website_validation_runs_for_website_contract_frontend_paths() {
8676        let contract = crate::agent::workspace_profile::RuntimeContract {
8677            loop_family: "website".to_string(),
8678            app_kind: "website".to_string(),
8679            framework_hint: Some("vite".to_string()),
8680            preferred_workflows: vec!["website_validate".to_string()],
8681            delivery_phases: vec!["design".to_string(), "validate".to_string()],
8682            verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
8683            quality_gates: vec!["critical routes return HTTP 200".to_string()],
8684            local_url_hint: Some("http://127.0.0.1:5173/".to_string()),
8685            route_hints: vec!["/".to_string()],
8686        };
8687        let mutated = std::collections::BTreeSet::from([
8688            "src/pages/index.tsx".to_string(),
8689            "public/app.css".to_string(),
8690        ]);
8691        assert!(should_run_website_validation(Some(&contract), &mutated));
8692    }
8693
8694    #[test]
8695    fn website_validation_skips_non_website_contracts() {
8696        let contract = crate::agent::workspace_profile::RuntimeContract {
8697            loop_family: "service".to_string(),
8698            app_kind: "node-service".to_string(),
8699            framework_hint: Some("express".to_string()),
8700            preferred_workflows: vec!["build".to_string()],
8701            delivery_phases: vec!["define boundary".to_string()],
8702            verification_workflows: vec!["build".to_string()],
8703            quality_gates: vec!["build stays green".to_string()],
8704            local_url_hint: None,
8705            route_hints: Vec::new(),
8706        };
8707        let mutated = std::collections::BTreeSet::from(["server.ts".to_string()]);
8708        assert!(!should_run_website_validation(Some(&contract), &mutated));
8709        assert!(!should_run_website_validation(None, &mutated));
8710    }
8711
8712    #[test]
8713    fn repeat_guard_exempts_structured_website_validation() {
8714        assert!(is_repeat_guard_exempt_tool_call(
8715            "run_workspace_workflow",
8716            &serde_json::json!({ "workflow": "website_validate" }),
8717        ));
8718        assert!(!is_repeat_guard_exempt_tool_call(
8719            "run_workspace_workflow",
8720            &serde_json::json!({ "workflow": "build" }),
8721        ));
8722    }
8723
8724    #[test]
8725    fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
8726        let (tool_name, args) = normalized_tool_call_for_execution(
8727            "shell",
8728            r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
8729            false,
8730            Some("Run the tests in this project."),
8731        );
8732
8733        assert_eq!(tool_name, "run_workspace_workflow");
8734        assert_eq!(
8735            args.get("workflow").and_then(|value| value.as_str()),
8736            Some("test")
8737        );
8738    }
8739
8740    #[test]
8741    fn scaffold_prompt_does_not_rewrite_to_workspace_workflow() {
8742        let (tool_name, _args) = normalized_tool_call_for_execution(
8743            "shell",
8744            r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
8745            false,
8746            Some("Make me a folder on my desktop named webtest2, and in that folder build a single-page website that explains the best uses of Hematite."),
8747        );
8748
8749        assert_eq!(tool_name, "shell");
8750    }
8751
8752    #[test]
8753    fn failing_path_parser_extracts_cargo_error_locations() {
8754        let output = r#"
8755BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
8756
8757error[E0412]: cannot find type `Foo` in this scope
8758  --> src/agent/conversation.rs:42:12
8759   |
876042 |     field: Foo,
8761   |            ^^^ not found
8762
8763error[E0308]: mismatched types
8764  --> src/tools/file_ops.rs:100:5
8765   |
8766   = note: expected `String`, found `&str`
8767"#;
8768        let paths = parse_failing_paths_from_build_output(output);
8769        assert!(
8770            paths.iter().any(|p| p.contains("conversation.rs")),
8771            "should capture conversation.rs"
8772        );
8773        assert!(
8774            paths.iter().any(|p| p.contains("file_ops.rs")),
8775            "should capture file_ops.rs"
8776        );
8777        assert_eq!(paths.len(), 2, "no duplicates");
8778    }
8779
8780    #[test]
8781    fn failing_path_parser_ignores_macro_expansions() {
8782        let output = r#"
8783  --> <macro-expansion>:1:2
8784  --> src/real/file.rs:10:5
8785"#;
8786        let paths = parse_failing_paths_from_build_output(output);
8787        assert_eq!(paths.len(), 1);
8788        assert!(paths[0].contains("file.rs"));
8789    }
8790
8791    #[test]
8792    fn intent_router_picks_updates_for_update_questions() {
8793        assert_eq!(
8794            preferred_host_inspection_topic("is my PC up to date?"),
8795            Some("updates")
8796        );
8797        assert_eq!(
8798            preferred_host_inspection_topic("are there any pending Windows updates?"),
8799            Some("updates")
8800        );
8801        assert_eq!(
8802            preferred_host_inspection_topic("check for updates on my computer"),
8803            Some("updates")
8804        );
8805    }
8806
8807    #[test]
8808    fn intent_router_picks_security_for_antivirus_questions() {
8809        assert_eq!(
8810            preferred_host_inspection_topic("is my antivirus on?"),
8811            Some("security")
8812        );
8813        assert_eq!(
8814            preferred_host_inspection_topic("is Windows Defender running?"),
8815            Some("security")
8816        );
8817        assert_eq!(
8818            preferred_host_inspection_topic("is my PC protected?"),
8819            Some("security")
8820        );
8821    }
8822
8823    #[test]
8824    fn intent_router_picks_pending_reboot_for_restart_questions() {
8825        assert_eq!(
8826            preferred_host_inspection_topic("do I need to restart my PC?"),
8827            Some("pending_reboot")
8828        );
8829        assert_eq!(
8830            preferred_host_inspection_topic("is a reboot required?"),
8831            Some("pending_reboot")
8832        );
8833        assert_eq!(
8834            preferred_host_inspection_topic("is there a pending restart waiting?"),
8835            Some("pending_reboot")
8836        );
8837    }
8838
8839    #[test]
8840    fn intent_router_picks_disk_health_for_drive_health_questions() {
8841        assert_eq!(
8842            preferred_host_inspection_topic("is my hard drive dying?"),
8843            Some("disk_health")
8844        );
8845        assert_eq!(
8846            preferred_host_inspection_topic("check the disk health and SMART status"),
8847            Some("disk_health")
8848        );
8849        assert_eq!(
8850            preferred_host_inspection_topic("is my SSD healthy?"),
8851            Some("disk_health")
8852        );
8853    }
8854
8855    #[test]
8856    fn intent_router_picks_battery_for_battery_questions() {
8857        assert_eq!(
8858            preferred_host_inspection_topic("check my battery"),
8859            Some("battery")
8860        );
8861        assert_eq!(
8862            preferred_host_inspection_topic("how is my battery life?"),
8863            Some("battery")
8864        );
8865        assert_eq!(
8866            preferred_host_inspection_topic("what is my battery wear level?"),
8867            Some("battery")
8868        );
8869    }
8870
8871    #[test]
8872    fn intent_router_picks_recent_crashes_for_bsod_questions() {
8873        assert_eq!(
8874            preferred_host_inspection_topic("why did my PC restart by itself?"),
8875            Some("recent_crashes")
8876        );
8877        assert_eq!(
8878            preferred_host_inspection_topic("did my computer BSOD recently?"),
8879            Some("recent_crashes")
8880        );
8881        assert_eq!(
8882            preferred_host_inspection_topic("show me any recent app crashes"),
8883            Some("recent_crashes")
8884        );
8885    }
8886
8887    #[test]
8888    fn intent_router_picks_scheduled_tasks_for_task_questions() {
8889        assert_eq!(
8890            preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
8891            Some("scheduled_tasks")
8892        );
8893        assert_eq!(
8894            preferred_host_inspection_topic("show me the task scheduler"),
8895            Some("scheduled_tasks")
8896        );
8897    }
8898
8899    #[test]
8900    fn intent_router_picks_dev_conflicts_for_conflict_questions() {
8901        assert_eq!(
8902            preferred_host_inspection_topic("are there any dev environment conflicts?"),
8903            Some("dev_conflicts")
8904        );
8905        assert_eq!(
8906            preferred_host_inspection_topic("why is python pointing to the wrong version?"),
8907            Some("dev_conflicts")
8908        );
8909    }
8910
8911    #[test]
8912    fn shell_guard_catches_windows_update_commands() {
8913        assert!(shell_looks_like_structured_host_inspection(
8914            "Get-WindowsUpdateLog | Select-Object -Last 50"
8915        ));
8916        assert!(shell_looks_like_structured_host_inspection(
8917            "$sess = New-Object -ComObject Microsoft.Update.Session"
8918        ));
8919        assert!(shell_looks_like_structured_host_inspection(
8920            "Get-Service wuauserv"
8921        ));
8922        assert!(shell_looks_like_structured_host_inspection(
8923            "Get-MpComputerStatus"
8924        ));
8925        assert!(shell_looks_like_structured_host_inspection(
8926            "Get-PhysicalDisk"
8927        ));
8928        assert!(shell_looks_like_structured_host_inspection(
8929            "Get-CimInstance Win32_Battery"
8930        ));
8931        assert!(shell_looks_like_structured_host_inspection(
8932            "Get-WinEvent -FilterHashtable @{Id=41}"
8933        ));
8934        assert!(shell_looks_like_structured_host_inspection(
8935            "Get-ScheduledTask | Where-Object State -ne Disabled"
8936        ));
8937    }
8938
8939    #[test]
8940    fn intent_router_picks_permissions_for_acl_questions() {
8941        assert_eq!(
8942            preferred_host_inspection_topic("who has permission to access the downloads folder?"),
8943            Some("permissions")
8944        );
8945        assert_eq!(
8946            preferred_host_inspection_topic("audit the ntfs permissions for this path"),
8947            Some("permissions")
8948        );
8949    }
8950
8951    #[test]
8952    fn intent_router_picks_login_history_for_logon_questions() {
8953        assert_eq!(
8954            preferred_host_inspection_topic("who logged in recently on this machine?"),
8955            Some("login_history")
8956        );
8957        assert_eq!(
8958            preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
8959            Some("login_history")
8960        );
8961    }
8962
8963    #[test]
8964    fn intent_router_picks_share_access_for_unc_questions() {
8965        assert_eq!(
8966            preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
8967            Some("share_access")
8968        );
8969        assert_eq!(
8970            preferred_host_inspection_topic("test accessibility of a network share"),
8971            Some("share_access")
8972        );
8973    }
8974
8975    #[test]
8976    fn intent_router_picks_registry_audit_for_persistence_questions() {
8977        assert_eq!(
8978            preferred_host_inspection_topic(
8979                "audit my registry for persistence hacks or debugger hijacking"
8980            ),
8981            Some("registry_audit")
8982        );
8983        assert_eq!(
8984            preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
8985            Some("registry_audit")
8986        );
8987    }
8988
8989    #[test]
8990    fn intent_router_picks_network_stats_for_mbps_questions() {
8991        assert_eq!(
8992            preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
8993            Some("network_stats")
8994        );
8995    }
8996
8997    #[test]
8998    fn intent_router_picks_processes_for_cpu_percentage_questions() {
8999        assert_eq!(
9000            preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
9001            Some("processes")
9002        );
9003    }
9004
9005    #[test]
9006    fn intent_router_picks_log_check_for_recent_window_questions() {
9007        assert_eq!(
9008            preferred_host_inspection_topic("show me system errors from the last 2 hours"),
9009            Some("log_check")
9010        );
9011    }
9012
9013    #[test]
9014    fn intent_router_picks_battery_for_health_and_cycles() {
9015        assert_eq!(
9016            preferred_host_inspection_topic("check my battery health and cycle count"),
9017            Some("battery")
9018        );
9019    }
9020
9021    #[test]
9022    fn intent_router_picks_thermal_for_throttling_questions() {
9023        assert_eq!(
9024            preferred_host_inspection_topic(
9025                "why is my laptop slow? check for overheating or throttling"
9026            ),
9027            Some("thermal")
9028        );
9029        assert_eq!(
9030            preferred_host_inspection_topic("show me the current cpu temp"),
9031            Some("thermal")
9032        );
9033    }
9034
9035    #[test]
9036    fn intent_router_picks_activation_for_genuine_questions() {
9037        assert_eq!(
9038            preferred_host_inspection_topic("is my windows genuine? check activation status"),
9039            Some("activation")
9040        );
9041        assert_eq!(
9042            preferred_host_inspection_topic("run slmgr to check my license state"),
9043            Some("activation")
9044        );
9045    }
9046
9047    #[test]
9048    fn intent_router_picks_patch_history_for_hotfix_questions() {
9049        assert_eq!(
9050            preferred_host_inspection_topic("show me the recently installed hotfixes"),
9051            Some("patch_history")
9052        );
9053        assert_eq!(
9054            preferred_host_inspection_topic(
9055                "list the windows update patch history for the last 48 hours"
9056            ),
9057            Some("patch_history")
9058        );
9059    }
9060
9061    #[test]
9062    fn intent_router_detects_multiple_symptoms_for_prerun() {
9063        let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
9064        assert!(topics.contains(&"thermal"));
9065        assert!(topics.contains(&"resource_load"));
9066        assert!(topics.contains(&"storage"));
9067        assert!(topics.len() >= 3);
9068    }
9069}