Skip to main content

hematite/agent/
conversation.rs

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