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