Skip to main content

hematite/agent/
conversation.rs

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