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        // ── Fast-path reset commands: handled locally, no network I/O needed ──
3110        if user_input.trim() == "/new" {
3111            self.history.clear();
3112            self.reasoning_history = None;
3113            self.session_memory.clear();
3114            self.running_summary = None;
3115            self.correction_hints.clear();
3116            self.pinned_files.write().await.clear();
3117            self.reset_action_grounding().await;
3118            reset_task_files();
3119            let _ = std::fs::remove_file(session_path());
3120            self.save_empty_session();
3121            self.emit_compaction_pressure(&tx).await;
3122            self.emit_prompt_pressure_idle(&tx).await;
3123            for chunk in chunk_text(
3124                "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
3125                8,
3126            ) {
3127                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3128            }
3129            let _ = tx.send(InferenceEvent::Done).await;
3130            return Ok(());
3131        }
3132
3133        if user_input.trim() == "/forget" {
3134            self.history.clear();
3135            self.reasoning_history = None;
3136            self.session_memory.clear();
3137            self.running_summary = None;
3138            self.correction_hints.clear();
3139            self.pinned_files.write().await.clear();
3140            self.reset_action_grounding().await;
3141            reset_task_files();
3142            crate::agent::tasks::clear();
3143            purge_persistent_memory();
3144            tokio::task::block_in_place(|| self.vein.reset());
3145            let _ = std::fs::remove_file(session_path());
3146            self.save_empty_session();
3147            self.emit_compaction_pressure(&tx).await;
3148            self.emit_prompt_pressure_idle(&tx).await;
3149            for chunk in chunk_text(
3150                "Hard forget complete. Chat history, saved memory, task files, task list, and the Vein index were purged.",
3151                8,
3152            ) {
3153                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3154            }
3155            let _ = tx.send(InferenceEvent::Done).await;
3156            return Ok(());
3157        }
3158
3159        if user_input.trim() == "/vein-inspect" {
3160            let indexed = self.refresh_vein_index();
3161            let report = self.build_vein_inspection_report(indexed);
3162            let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
3163            let _ = tx
3164                .send(InferenceEvent::VeinStatus {
3165                    file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
3166                    embedded_count: snapshot.embedded_source_doc_chunks,
3167                    docs_only: self.vein_docs_only_mode(),
3168                })
3169                .await;
3170            for chunk in chunk_text(&report, 8) {
3171                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3172            }
3173            let _ = tx.send(InferenceEvent::Done).await;
3174            return Ok(());
3175        }
3176
3177        if user_input.trim() == "/workspace-profile" {
3178            let root = crate::tools::file_ops::workspace_root();
3179            let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
3180            let report = crate::agent::workspace_profile::profile_report(&root);
3181            for chunk in chunk_text(&report, 8) {
3182                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3183            }
3184            let _ = tx.send(InferenceEvent::Done).await;
3185            return Ok(());
3186        }
3187
3188        if user_input.trim() == "/rules" {
3189            let workspace_root = crate::tools::file_ops::workspace_root();
3190            let report = {
3191                let mut combined = String::with_capacity(
3192                    crate::agent::instructions::PROJECT_GUIDANCE_FILES.len() * 512,
3193                );
3194                for name in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
3195                    let path =
3196                        crate::agent::instructions::resolve_guidance_path(&workspace_root, name);
3197                    if !path.exists() {
3198                        continue;
3199                    }
3200                    match std::fs::read_to_string(&path) {
3201                        Ok(content) => {
3202                            let _ = write!(combined, "## {}\n\n{}\n\n", name, content.trim());
3203                        }
3204                        Err(e) => {
3205                            let _ = write!(
3206                                combined,
3207                                "## {}\n\nError reading {}: {}\n\n",
3208                                name,
3209                                path.display(),
3210                                e
3211                            );
3212                        }
3213                    }
3214                }
3215                if combined.is_empty() {
3216                    "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()
3217                } else {
3218                    format!(
3219                        "## 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.",
3220                        combined
3221                    )
3222                }
3223            };
3224            for chunk in chunk_text(&report, 8) {
3225                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3226            }
3227            let _ = tx.send(InferenceEvent::Done).await;
3228            return Ok(());
3229        }
3230
3231        if user_input.trim() == "/skills" {
3232            let workspace_root = crate::tools::file_ops::workspace_root();
3233            let config = crate::agent::config::load_config();
3234            let discovery =
3235                crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
3236            let report = crate::agent::instructions::render_skills_report(&discovery);
3237            for chunk in chunk_text(&report, 8) {
3238                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3239            }
3240            let _ = tx.send(InferenceEvent::Done).await;
3241            return Ok(());
3242        }
3243
3244        // /skill <name> — explicitly load a skill's full body for the next turn.
3245        if let Some(skill_name) = user_input
3246            .trim()
3247            .strip_prefix("/skill ")
3248            .map(str::trim)
3249            .filter(|s| !s.is_empty())
3250        {
3251            let workspace_root = crate::tools::file_ops::workspace_root();
3252            let config = crate::agent::config::load_config();
3253            let discovery =
3254                crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
3255            let name_lower = skill_name.to_lowercase();
3256            if let Some(skill) = discovery
3257                .skills
3258                .iter()
3259                .find(|s| s.name.to_lowercase() == name_lower)
3260            {
3261                if skill.body.is_empty() {
3262                    let msg = format!(
3263                        "Skill `{}` found but its SKILL.md has no body — add instructions after the frontmatter.",
3264                        skill.name
3265                    );
3266                    for chunk in chunk_text(&msg, 8) {
3267                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
3268                    }
3269                } else {
3270                    self.pending_skill_inject =
3271                        Some(format!("## Skill: {}\n{}", skill.name, skill.body));
3272                    let msg = format!(
3273                        "Skill `{}` loaded — instructions will be active for the next turn.",
3274                        skill.name
3275                    );
3276                    for chunk in chunk_text(&msg, 8) {
3277                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
3278                    }
3279                }
3280            } else {
3281                let available: Vec<&str> =
3282                    discovery.skills.iter().map(|s| s.name.as_str()).collect();
3283                let msg = if available.is_empty() {
3284                    format!(
3285                        "No skill named `{}` found. No skills are currently discovered.",
3286                        skill_name
3287                    )
3288                } else {
3289                    format!(
3290                        "No skill named `{}` found. Available: {}",
3291                        skill_name,
3292                        available.join(", ")
3293                    )
3294                };
3295                for chunk in chunk_text(&msg, 8) {
3296                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3297                }
3298            }
3299            let _ = tx.send(InferenceEvent::Done).await;
3300            return Ok(());
3301        }
3302
3303        // /skill new <name> — scaffold a SKILL.md skeleton in .agents/skills/<name>/
3304        if let Some(new_name) = user_input
3305            .trim()
3306            .strip_prefix("/skill new ")
3307            .map(str::trim)
3308            .filter(|s| !s.is_empty())
3309        {
3310            let slug = new_name
3311                .to_lowercase()
3312                .replace(' ', "-")
3313                .chars()
3314                .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
3315                .collect::<String>();
3316            let skill_dir = crate::tools::file_ops::workspace_root()
3317                .join(".agents")
3318                .join("skills")
3319                .join(&slug);
3320            let skill_path = skill_dir.join("SKILL.md");
3321            let msg = if skill_path.exists() {
3322                format!(
3323                    "Skill `{}` already exists at `{}`.",
3324                    slug,
3325                    skill_path.display()
3326                )
3327            } else {
3328                match std::fs::create_dir_all(&skill_dir) {
3329                    Err(e) => format!("Failed to create skill directory: {}", e),
3330                    Ok(()) => {
3331                        let template = format!(
3332                            "---\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"
3333                        );
3334                        match std::fs::write(&skill_path, template) {
3335                            Ok(()) => format!(
3336                                "Created `{}` — edit the description, triggers, and instructions, then use `/skill {}` to load it.",
3337                                skill_path.display(),
3338                                slug
3339                            ),
3340                            Err(e) => format!("Failed to write SKILL.md: {}", e),
3341                        }
3342                    }
3343                }
3344            };
3345            for chunk in chunk_text(&msg, 8) {
3346                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3347            }
3348            let _ = tx.send(InferenceEvent::Done).await;
3349            return Ok(());
3350        }
3351
3352        if user_input.trim() == "/vein-reset" {
3353            tokio::task::block_in_place(|| self.vein.reset());
3354            let _ = tx
3355                .send(InferenceEvent::VeinStatus {
3356                    file_count: 0,
3357                    embedded_count: 0,
3358                    docs_only: self.vein_docs_only_mode(),
3359                })
3360                .await;
3361            for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
3362                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3363            }
3364            let _ = tx.send(InferenceEvent::Done).await;
3365            return Ok(());
3366        }
3367
3368        if user_input.trim() == "/compact" {
3369            let context_length = self.engine.current_context_length();
3370            let vram_ratio = self.gpu_state.ratio();
3371            let config = compaction::CompactionConfig::adaptive(context_length, vram_ratio);
3372            let before_len = self.history.len();
3373            let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
3374            let result = compaction::compact_history(
3375                &self.history,
3376                self.running_summary.as_deref(),
3377                config,
3378                None,
3379            );
3380            let removed = before_len.saturating_sub(result.messages.len());
3381            self.history = result.messages;
3382            self.running_summary = result.summary;
3383            let last_checkpoint = self.session_memory.last_checkpoint.take();
3384            let last_blocker = self.session_memory.last_blocker.take();
3385            let last_recovery = self.session_memory.last_recovery.take();
3386            let last_verification = self.session_memory.last_verification.take();
3387            let last_compaction = self.session_memory.last_compaction.take();
3388            self.session_memory = compaction::extract_memory(&self.history);
3389            self.session_memory.last_checkpoint = last_checkpoint;
3390            self.session_memory.last_blocker = last_blocker;
3391            self.session_memory.last_recovery = last_recovery;
3392            self.session_memory.last_verification = last_verification;
3393            self.session_memory.last_compaction = last_compaction;
3394            self.session_memory.record_compaction(
3395                removed,
3396                format!(
3397                    "Manual /compact: task '{}', {} file(s) in working set.",
3398                    self.session_memory.current_task,
3399                    self.session_memory.working_set.len()
3400                ),
3401            );
3402            self.emit_compaction_pressure(&tx).await;
3403            let after_tokens = compaction::estimate_compactable_tokens(&self.history);
3404            let msg = format!(
3405                "History compacted. {} message(s) summarized, ~{} tokens freed. \
3406                 Remaining: ~{} tokens. Active task: \"{}\".",
3407                removed,
3408                estimated_tokens.saturating_sub(after_tokens),
3409                after_tokens,
3410                self.session_memory.current_task,
3411            );
3412            for chunk in chunk_text(&msg, 8) {
3413                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3414            }
3415            let _ = tx.send(InferenceEvent::Done).await;
3416            return Ok(());
3417        }
3418
3419        if user_input.trim() == "/budget" {
3420            let msg = match &self.last_turn_budget {
3421                Some(b) => b.render(),
3422                None => "No turn budget recorded yet — run a prompt first.".to_string(),
3423            };
3424            for chunk in chunk_text(&msg, 8) {
3425                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3426            }
3427            let _ = tx.send(InferenceEvent::Done).await;
3428            return Ok(());
3429        }
3430
3431        // ── /task commands ───────────────────────────────────────────────────────
3432        {
3433            let trimmed = user_input.trim();
3434
3435            // /task or /task list — show current tasks
3436            if trimmed == "/task" || trimmed == "/task list" {
3437                let tasks = crate::agent::tasks::load();
3438                let report = crate::agent::tasks::render_list(&tasks);
3439                for chunk in chunk_text(&report, 8) {
3440                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3441                }
3442                let _ = tx.send(InferenceEvent::Done).await;
3443                return Ok(());
3444            }
3445
3446            // /task add <text>
3447            if let Some(text) = trimmed
3448                .strip_prefix("/task add ")
3449                .map(str::trim)
3450                .filter(|s| !s.is_empty())
3451            {
3452                let tasks = crate::agent::tasks::add(text);
3453                let added = tasks
3454                    .iter()
3455                    .find(|t| t.text == text.trim())
3456                    .map(|t| t.id)
3457                    .unwrap_or(0);
3458                let msg = format!("Task {} added: {}", added, text.trim());
3459                for chunk in chunk_text(&msg, 8) {
3460                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3461                }
3462                let _ = tx.send(InferenceEvent::Done).await;
3463                return Ok(());
3464            }
3465
3466            // /task done <N>
3467            if let Some(n_str) = trimmed.strip_prefix("/task done ").map(str::trim) {
3468                let msg = match n_str.parse::<usize>() {
3469                    Ok(n) => match crate::agent::tasks::mark_done(n) {
3470                        Ok(tasks) => {
3471                            let task = tasks.iter().find(|t| t.id == n);
3472                            format!(
3473                                "Task {} marked done: {}",
3474                                n,
3475                                task.map(|t| t.text.as_str()).unwrap_or("")
3476                            )
3477                        }
3478                        Err(e) => e,
3479                    },
3480                    Err(_) => "Usage: /task done <number>  (e.g. `/task done 2`)".to_string(),
3481                };
3482                for chunk in chunk_text(&msg, 8) {
3483                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3484                }
3485                let _ = tx.send(InferenceEvent::Done).await;
3486                return Ok(());
3487            }
3488
3489            // /task remove <N>
3490            if let Some(n_str) = trimmed.strip_prefix("/task remove ").map(str::trim) {
3491                let msg = match n_str.parse::<usize>() {
3492                    Ok(n) => match crate::agent::tasks::remove(n) {
3493                        Ok(_) => format!("Task {} removed.", n),
3494                        Err(e) => e,
3495                    },
3496                    Err(_) => "Usage: /task remove <number>  (e.g. `/task remove 3`)".to_string(),
3497                };
3498                for chunk in chunk_text(&msg, 8) {
3499                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3500                }
3501                let _ = tx.send(InferenceEvent::Done).await;
3502                return Ok(());
3503            }
3504
3505            // /task clear
3506            if trimmed == "/task clear" {
3507                crate::agent::tasks::clear();
3508                for chunk in chunk_text("All tasks cleared.", 8) {
3509                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3510                }
3511                let _ = tx.send(InferenceEvent::Done).await;
3512                return Ok(());
3513            }
3514        }
3515
3516        // ── GitHub slash commands (harness-driven, no model) ─────────────────
3517        {
3518            let trimmed = user_input.trim();
3519
3520            // /pr [--draft] [title]
3521            if trimmed == "/pr" || trimmed.starts_with("/pr ") {
3522                let rest = trimmed.strip_prefix("/pr").unwrap_or("").trim();
3523                let draft = rest.contains("--draft");
3524                let title_part = rest.trim_start_matches("--draft").trim();
3525                let title = if title_part.is_empty() {
3526                    None
3527                } else {
3528                    Some(title_part)
3529                };
3530                let msg = match crate::tools::github::create_pr_from_context(title, draft) {
3531                    Ok(out) => out,
3532                    Err(e) => format!("PR creation failed: {}", e),
3533                };
3534                for chunk in chunk_text(&msg, 8) {
3535                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3536                }
3537                let _ = tx.send(InferenceEvent::Done).await;
3538                return Ok(());
3539            }
3540
3541            // /ci
3542            if trimmed == "/ci" {
3543                let msg = match crate::tools::github::ci_status_current() {
3544                    Ok(out) if out.trim().is_empty() => {
3545                        "No CI runs found for this branch. Push to GitHub and trigger a workflow first.".to_string()
3546                    }
3547                    Ok(out) => format!("## CI Status\n\n```\n{}\n```", out.trim()),
3548                    Err(e) => format!("CI status failed: {}", e),
3549                };
3550                for chunk in chunk_text(&msg, 8) {
3551                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3552                }
3553                let _ = tx.send(InferenceEvent::Done).await;
3554                return Ok(());
3555            }
3556
3557            // /issue
3558            if trimmed == "/issue" || trimmed.starts_with("/issue ") {
3559                let rest = trimmed.strip_prefix("/issue").unwrap_or("").trim();
3560                let args = if rest.is_empty() {
3561                    serde_json::json!({ "action": "issue_list", "limit": 10 })
3562                } else if let Ok(n) = rest.parse::<u64>() {
3563                    serde_json::json!({ "action": "issue_view", "number": n })
3564                } else {
3565                    serde_json::json!({ "action": "issue_list", "limit": 10, "state": rest })
3566                };
3567                let msg = match crate::tools::github::execute(&args).await {
3568                    Ok(out) if out.trim().is_empty() => "No issues found.".to_string(),
3569                    Ok(out) => format!("## Issues\n\n```\n{}\n```", out.trim()),
3570                    Err(e) => format!("Issue lookup failed: {}", e),
3571                };
3572                for chunk in chunk_text(&msg, 8) {
3573                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3574                }
3575                let _ = tx.send(InferenceEvent::Done).await;
3576                return Ok(());
3577            }
3578        }
3579
3580        // ── /fix — run verify_build now, load error into next-turn intervention ──
3581        if user_input.trim() == "/fix" || user_input.trim() == "/fix --test" {
3582            let action = if user_input.trim() == "/fix --test" {
3583                "test"
3584            } else {
3585                "build"
3586            };
3587            let _ = tx
3588                .send(InferenceEvent::Thought(format!(
3589                    "Running verify_build({action}) to capture current error state..."
3590                )))
3591                .await;
3592            let result =
3593                crate::tools::verify_build::execute(&serde_json::json!({ "action": action })).await;
3594            let (ok, output) = match result {
3595                Ok(out) => (true, out),
3596                Err(e) => (false, e),
3597            };
3598            if ok {
3599                for chunk in chunk_text(
3600                    &format!(
3601                        "Build is clean — nothing to fix.\n\n```\n{}\n```",
3602                        output.trim()
3603                    ),
3604                    8,
3605                ) {
3606                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3607                }
3608            } else {
3609                // Stream the error so the user sees it.
3610                let capped: String = output.chars().take(3000).collect();
3611                for chunk in chunk_text(
3612                    &format!(
3613                        "Build failed. Fix context loaded — send any message to start fixing.\n\n```\n{}\n```",
3614                        capped.trim()
3615                    ),
3616                    8,
3617                ) {
3618                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
3619                }
3620                self.pending_fix_context = Some(capped);
3621            }
3622            let _ = tx.send(InferenceEvent::Done).await;
3623            return Ok(());
3624        }
3625
3626        // Reload config every turn (edits apply immediately, no restart needed).
3627        let config = crate::agent::config::load_config();
3628        self.recovery_context.clear();
3629        let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
3630        if !manual_runtime_refresh {
3631            if let Some((model_id, context_length, changed)) = self
3632                .refresh_runtime_profile_and_report(&tx, "turn_start")
3633                .await
3634            {
3635                if changed {
3636                    let _ = tx
3637                        .send(InferenceEvent::Thought(format!(
3638                            "Runtime refresh: using model `{}` with CTX {} for this turn.",
3639                            model_id, context_length
3640                        )))
3641                        .await;
3642                }
3643            }
3644        }
3645        self.emit_embed_profile(&tx).await;
3646        self.emit_compaction_pressure(&tx).await;
3647        let current_model = self.engine.current_model();
3648        self.engine.set_gemma_native_formatting(
3649            crate::agent::config::effective_gemma_native_formatting(&config, &current_model),
3650        );
3651        let _turn_id = self.begin_grounded_turn().await;
3652        let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
3653        let mcp_tools = match self.refresh_mcp_tools(&tx).await {
3654            Ok(tools) => tools,
3655            Err(e) => {
3656                let _ = tx
3657                    .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
3658                    .await;
3659                Vec::new()
3660            }
3661        };
3662
3663        // Apply config model overrides (config takes precedence over CLI flags).
3664        let effective_fast = config
3665            .fast_model
3666            .clone()
3667            .or_else(|| self.fast_model.clone());
3668        let effective_think = config
3669            .think_model
3670            .clone()
3671            .or_else(|| self.think_model.clone());
3672
3673        let trimmed_input = user_input.trim();
3674
3675        if trimmed_input == "/model" || trimmed_input.starts_with("/model ") {
3676            let arg_text = trimmed_input.strip_prefix("/model").unwrap_or("").trim();
3677            let response = if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
3678                Ok(self.runtime_model_status_report(&config).await)
3679            } else if let Some(list_args) = arg_text.strip_prefix("list").map(str::trim) {
3680                let loaded_only = if list_args.is_empty()
3681                    || list_args.eq_ignore_ascii_case("available")
3682                {
3683                    false
3684                } else if list_args.eq_ignore_ascii_case("loaded") {
3685                    true
3686                } else {
3687                    for chunk in chunk_text(&format!("Usage: {}", Self::model_command_usage()), 8) {
3688                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
3689                    }
3690                    let _ = tx.send(InferenceEvent::Done).await;
3691                    return Ok(());
3692                };
3693                let provider = self.engine.provider_name().await;
3694                self.format_provider_model_inventory(
3695                    &provider,
3696                    crate::agent::provider::ProviderModelKind::Coding,
3697                    loaded_only,
3698                )
3699                .await
3700            } else if let Some(load_args) = arg_text.strip_prefix("load ").map(str::trim) {
3701                if load_args.is_empty() {
3702                    Err(format!("Usage: {}", Self::model_command_usage()))
3703                } else {
3704                    let (model_id, context_length) = Self::parse_model_load_args(load_args)?;
3705                    self.load_runtime_model_now(&tx, &model_id, "coding", context_length)
3706                        .await
3707                }
3708            } else if let Some(unload_args) = arg_text.strip_prefix("unload").map(str::trim) {
3709                let (target, unload_all) = Self::parse_unload_target(unload_args)?;
3710                self.unload_runtime_model_now(&tx, target.as_deref(), "coding", unload_all)
3711                    .await
3712            } else if let Some(model_id) = arg_text.strip_prefix("prefer ").map(str::trim) {
3713                if model_id.is_empty() {
3714                    Err(format!("Usage: {}", Self::model_command_usage()))
3715                } else {
3716                    crate::agent::config::set_preferred_coding_model(Some(model_id)).map(|_| {
3717                        format!(
3718                            "Saved preferred coding model `{}` in `.hematite/settings.json`. Use `/model load {}` now or restart Hematite to let startup policy load it automatically.",
3719                            model_id, model_id
3720                        )
3721                    })
3722                }
3723            } else if matches!(arg_text, "clear" | "clear-preference") {
3724                crate::agent::config::set_preferred_coding_model(None)
3725                    .map(|_| "Cleared the saved preferred coding model.".to_string())
3726            } else {
3727                Err(format!("Usage: {}", Self::model_command_usage()))
3728            };
3729
3730            for chunk in chunk_text(&response.unwrap_or_else(|e| e), 8) {
3731                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3732            }
3733            let _ = tx.send(InferenceEvent::Done).await;
3734            return Ok(());
3735        }
3736
3737        if trimmed_input == "/embed" || trimmed_input.starts_with("/embed ") {
3738            let arg_text = trimmed_input.strip_prefix("/embed").unwrap_or("").trim();
3739            let response = if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
3740                Ok(self.runtime_model_status_report(&config).await)
3741            } else if let Some(load_args) = arg_text.strip_prefix("load ").map(str::trim) {
3742                if load_args.is_empty() {
3743                    Err(format!("Usage: {}", Self::embed_command_usage()))
3744                } else {
3745                    let (model_id, context_length) = Self::parse_model_load_args(load_args)?;
3746                    if context_length.is_some() {
3747                        Err("`/embed load` does not accept `--ctx`. Embedding models do not use a chat context window here.".to_string())
3748                    } else {
3749                        self.load_runtime_model_now(&tx, &model_id, "embed", None)
3750                            .await
3751                    }
3752                }
3753            } else if let Some(unload_args) = arg_text.strip_prefix("unload").map(str::trim) {
3754                let (target, unload_all) = Self::parse_unload_target(unload_args)?;
3755                if unload_all {
3756                    Err("`/embed unload` supports the current embed model or an explicit embed model ID, not `all`.".to_string())
3757                } else {
3758                    self.unload_runtime_model_now(&tx, target.as_deref(), "embed", false)
3759                        .await
3760                }
3761            } else if let Some(model_id) = arg_text.strip_prefix("prefer ").map(str::trim) {
3762                if model_id.is_empty() {
3763                    Err(format!("Usage: {}", Self::embed_command_usage()))
3764                } else {
3765                    crate::agent::config::set_preferred_embed_model(Some(model_id)).map(|_| {
3766                        format!(
3767                            "Saved preferred embed model `{}` in `.hematite/settings.json`. Use `/embed load {}` now or restart Hematite to let startup policy load it automatically.",
3768                            model_id, model_id
3769                        )
3770                    })
3771                }
3772            } else if matches!(arg_text, "clear" | "clear-preference") {
3773                crate::agent::config::set_preferred_embed_model(None)
3774                    .map(|_| "Cleared the saved preferred embed model.".to_string())
3775            } else {
3776                Err(format!("Usage: {}", Self::embed_command_usage()))
3777            };
3778
3779            for chunk in chunk_text(&response.unwrap_or_else(|e| e), 8) {
3780                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3781            }
3782            let _ = tx.send(InferenceEvent::Done).await;
3783            return Ok(());
3784        }
3785
3786        // ── /lsp: start language servers manually if needed ──────────────────
3787        if user_input.trim() == "/lsp" {
3788            let mut lsp = self.lsp_manager.lock().await;
3789            match lsp.start_servers().await {
3790                Ok(_) => {
3791                    let _ = tx
3792                        .send(InferenceEvent::MutedToken(
3793                            "LSP: Servers Initialized OK.".to_string(),
3794                        ))
3795                        .await;
3796                }
3797                Err(e) => {
3798                    let _ = tx
3799                        .send(InferenceEvent::Error(format!(
3800                            "LSP: Failed to start servers - {}",
3801                            e
3802                        )))
3803                        .await;
3804                }
3805            }
3806            let _ = tx.send(InferenceEvent::Done).await;
3807            return Ok(());
3808        }
3809
3810        if user_input.trim() == "/runtime-refresh" {
3811            match self
3812                .refresh_runtime_profile_and_report(&tx, "manual_command")
3813                .await
3814            {
3815                Some((model_id, context_length, changed)) => {
3816                    let msg = if changed {
3817                        format!(
3818                            "Runtime profile refreshed. Model: {} | CTX: {}",
3819                            model_id, context_length
3820                        )
3821                    } else {
3822                        format!(
3823                            "Runtime profile unchanged. Model: {} | CTX: {}",
3824                            model_id, context_length
3825                        )
3826                    };
3827                    for chunk in chunk_text(&msg, 8) {
3828                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
3829                    }
3830                }
3831                None => {
3832                    let provider_name = self.engine.provider_name().await;
3833                    let endpoint = crate::runtime::session_endpoint_url(&self.engine.base_url);
3834                    let alternative =
3835                        crate::runtime::detect_alternative_provider(&provider_name).await;
3836                    let mut message = format!(
3837                        "Runtime refresh failed: {} could not be read at {}.",
3838                        provider_name, endpoint
3839                    );
3840                    if let Some((alt_name, alt_url)) = alternative {
3841                        let _ = write!(
3842                            message,
3843                            " Reachable alternative detected: {} ({})",
3844                            alt_name, alt_url
3845                        );
3846                    }
3847                    let _ = tx.send(InferenceEvent::Error(message)).await;
3848                }
3849            }
3850            let _ = tx.send(InferenceEvent::Done).await;
3851            return Ok(());
3852        }
3853
3854        if user_input.trim() == "/ask" {
3855            self.set_workflow_mode(WorkflowMode::Ask);
3856            for chunk in chunk_text(
3857                "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
3858                8,
3859            ) {
3860                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3861            }
3862            let _ = tx.send(InferenceEvent::Done).await;
3863            return Ok(());
3864        }
3865
3866        if user_input.trim() == "/code" {
3867            self.set_workflow_mode(WorkflowMode::Code);
3868            let mut message =
3869                "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
3870            if let Some(plan) = self.current_plan_summary() {
3871                let _ = write!(message, " Current plan: {plan}.");
3872            }
3873            for chunk in chunk_text(&message, 8) {
3874                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3875            }
3876            let _ = tx.send(InferenceEvent::Done).await;
3877            return Ok(());
3878        }
3879
3880        if user_input.trim() == "/architect" {
3881            self.set_workflow_mode(WorkflowMode::Architect);
3882            let mut message =
3883                "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();
3884            if let Some(plan) = self.current_plan_summary() {
3885                let _ = write!(message, " Existing plan: {plan}.");
3886            }
3887            for chunk in chunk_text(&message, 8) {
3888                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3889            }
3890            let _ = tx.send(InferenceEvent::Done).await;
3891            return Ok(());
3892        }
3893
3894        if user_input.trim() == "/read-only" {
3895            self.set_workflow_mode(WorkflowMode::ReadOnly);
3896            for chunk in chunk_text(
3897                "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
3898                8,
3899            ) {
3900                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3901            }
3902            let _ = tx.send(InferenceEvent::Done).await;
3903            return Ok(());
3904        }
3905
3906        if user_input.trim() == "/auto" {
3907            self.set_workflow_mode(WorkflowMode::Auto);
3908            for chunk in chunk_text(
3909                "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
3910                8,
3911            ) {
3912                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3913            }
3914            let _ = tx.send(InferenceEvent::Done).await;
3915            return Ok(());
3916        }
3917
3918        if user_input.trim() == "/chat" {
3919            self.set_workflow_mode(WorkflowMode::Chat);
3920            let _ = tx.send(InferenceEvent::Done).await;
3921            return Ok(());
3922        }
3923
3924        if user_input.trim() == "/teach" {
3925            self.set_workflow_mode(WorkflowMode::Teach);
3926            for chunk in chunk_text(
3927                "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.",
3928                8,
3929            ) {
3930                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3931            }
3932            let _ = tx.send(InferenceEvent::Done).await;
3933            return Ok(());
3934        }
3935
3936        if user_input.trim() == "/reroll" {
3937            let soul = crate::ui::hatch::generate_soul_random();
3938            self.snark = soul.snark;
3939            self.chaos = soul.chaos;
3940            self.soul_personality = soul.personality.clone();
3941            // Update the engine's species name so build_chat_system_prompt uses it
3942            // SAFETY: engine is Arc but species is a plain String field we own logically.
3943            // We use Arc::get_mut which only succeeds if this is the only strong ref.
3944            // If it fails (swarm workers hold refs), we fall back to a best-effort clone approach.
3945            let species = soul.species.clone();
3946            if let Some(eng) = Arc::get_mut(&mut self.engine) {
3947                eng.species = species.clone();
3948            }
3949            let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
3950            let _ = tx
3951                .send(InferenceEvent::SoulReroll {
3952                    species: soul.species.clone(),
3953                    rarity: soul.rarity.label().to_string(),
3954                    shiny: soul.shiny,
3955                    personality: soul.personality.clone(),
3956                })
3957                .await;
3958            for chunk in chunk_text(
3959                &format!(
3960                    "A new companion awakens!\n[{}{}] {} — \"{}\"",
3961                    soul.rarity.label(),
3962                    shiny_tag,
3963                    soul.species,
3964                    soul.personality
3965                ),
3966                8,
3967            ) {
3968                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3969            }
3970            let _ = tx.send(InferenceEvent::Done).await;
3971            return Ok(());
3972        }
3973
3974        if user_input.trim() == "/agent" {
3975            self.set_workflow_mode(WorkflowMode::Auto);
3976            let _ = tx.send(InferenceEvent::Done).await;
3977            return Ok(());
3978        }
3979
3980        let implement_plan_alias = user_input.trim() == "/implement-plan";
3981        if implement_plan_alias
3982            && !self
3983                .session_memory
3984                .current_plan
3985                .as_ref()
3986                .map(|plan| plan.has_signal())
3987                .unwrap_or(false)
3988        {
3989            for chunk in chunk_text(
3990                "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
3991                8,
3992            ) {
3993                let _ = tx.send(InferenceEvent::Token(chunk)).await;
3994            }
3995            let _ = tx.send(InferenceEvent::Done).await;
3996            return Ok(());
3997        }
3998
3999        let mut effective_user_input = if implement_plan_alias {
4000            self.set_workflow_mode(WorkflowMode::Code);
4001            implement_current_plan_prompt().to_string()
4002        } else {
4003            user_input.trim().to_string()
4004        };
4005        if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
4006            self.set_workflow_mode(mode);
4007            effective_user_input = rest.to_string();
4008        }
4009        let transcript_user_input = if implement_plan_alias {
4010            transcript_user_turn_text(user_turn, "/implement-plan")
4011        } else {
4012            transcript_user_turn_text(user_turn, &effective_user_input)
4013        };
4014        effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
4015        // Register @file mentions in action_grounding so the model can edit them
4016        // immediately without a separate read_file round-trip.
4017        self.register_at_file_mentions(user_input).await;
4018        let implement_current_plan = self.workflow_mode == WorkflowMode::Code
4019            && is_current_plan_execution_request(&effective_user_input)
4020            && self
4021                .session_memory
4022                .current_plan
4023                .as_ref()
4024                .map(|plan| plan.has_signal())
4025                .unwrap_or(false);
4026        let explicit_search_request = is_explicit_web_search_request(&effective_user_input);
4027        let mut grounded_research_results: Option<String> = None;
4028        self.plan_execution_active
4029            .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
4030        let _plan_execution_guard = PlanExecutionGuard {
4031            flag: self.plan_execution_active.clone(),
4032        };
4033        let task_progress_before = if implement_current_plan {
4034            read_task_checklist_progress()
4035        } else {
4036            None
4037        };
4038        let current_plan_pass = if implement_current_plan {
4039            self.plan_execution_pass_depth
4040                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
4041                + 1
4042        } else {
4043            0
4044        };
4045        let _plan_execution_pass_guard = implement_current_plan.then(|| PlanExecutionPassGuard {
4046            depth: self.plan_execution_pass_depth.clone(),
4047        });
4048        let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
4049
4050        // Seamless Search Handover: investigation mode is turn-scoped in AUTO.
4051        if should_use_turn_scoped_investigation_mode(self.workflow_mode, intent.primary_class) {
4052            let _ = tx
4053                .send(InferenceEvent::Thought(
4054                    "Seamless search detected: using investigation mode for this turn...".into(),
4055                ))
4056                .await;
4057        }
4058
4059        // ── /think / /no_think: reasoning budget toggle ──────────────────────
4060        if let Some(answer_kind) = intent.direct_answer {
4061            match answer_kind {
4062                DirectAnswerKind::About => {
4063                    let response = build_about_answer();
4064                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4065                        .await;
4066                    return Ok(());
4067                }
4068                DirectAnswerKind::LanguageCapability => {
4069                    let response = build_language_capability_answer();
4070                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4071                        .await;
4072                    return Ok(());
4073                }
4074                DirectAnswerKind::UnsafeWorkflowPressure => {
4075                    let response = build_unsafe_workflow_pressure_answer();
4076                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4077                        .await;
4078                    return Ok(());
4079                }
4080                DirectAnswerKind::SessionMemory => {
4081                    let response = build_session_memory_answer();
4082                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4083                        .await;
4084                    return Ok(());
4085                }
4086                DirectAnswerKind::RecoveryRecipes => {
4087                    let response = build_recovery_recipes_answer();
4088                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4089                        .await;
4090                    return Ok(());
4091                }
4092                DirectAnswerKind::McpLifecycle => {
4093                    let response = build_mcp_lifecycle_answer();
4094                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4095                        .await;
4096                    return Ok(());
4097                }
4098                DirectAnswerKind::AuthorizationPolicy => {
4099                    let response = build_authorization_policy_answer();
4100                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4101                        .await;
4102                    return Ok(());
4103                }
4104                DirectAnswerKind::ToolClasses => {
4105                    let response = build_tool_classes_answer();
4106                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4107                        .await;
4108                    return Ok(());
4109                }
4110                DirectAnswerKind::ToolRegistryOwnership => {
4111                    let response = build_tool_registry_ownership_answer();
4112                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4113                        .await;
4114                    return Ok(());
4115                }
4116                DirectAnswerKind::SessionResetSemantics => {
4117                    let response = build_session_reset_semantics_answer();
4118                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4119                        .await;
4120                    return Ok(());
4121                }
4122                DirectAnswerKind::ProductSurface => {
4123                    let response = build_product_surface_answer();
4124                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4125                        .await;
4126                    return Ok(());
4127                }
4128                DirectAnswerKind::ReasoningSplit => {
4129                    let response = build_reasoning_split_answer();
4130                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4131                        .await;
4132                    return Ok(());
4133                }
4134                DirectAnswerKind::Identity => {
4135                    let response = build_identity_answer();
4136                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4137                        .await;
4138                    return Ok(());
4139                }
4140                DirectAnswerKind::WorkflowModes => {
4141                    let response = build_workflow_modes_answer();
4142                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4143                        .await;
4144                    return Ok(());
4145                }
4146                DirectAnswerKind::GemmaNative => {
4147                    let response = build_gemma_native_answer();
4148                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4149                        .await;
4150                    return Ok(());
4151                }
4152                DirectAnswerKind::GemmaNativeSettings => {
4153                    let response = build_gemma_native_settings_answer();
4154                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4155                        .await;
4156                    return Ok(());
4157                }
4158                DirectAnswerKind::VerifyProfiles => {
4159                    let response = build_verify_profiles_answer();
4160                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4161                        .await;
4162                    return Ok(());
4163                }
4164                DirectAnswerKind::Toolchain => {
4165                    let lower = effective_user_input.to_lowercase();
4166                    let topic = if (lower.contains("voice output") || lower.contains("voice"))
4167                        && (lower.contains("lag")
4168                            || lower.contains("behind visible text")
4169                            || lower.contains("latency"))
4170                    {
4171                        "voice_latency_plan"
4172                    } else {
4173                        "all"
4174                    };
4175                    let response =
4176                        crate::tools::toolchain::describe_toolchain(&serde_json::json!({
4177                            "topic": topic,
4178                            "question": effective_user_input,
4179                        }))
4180                        .await
4181                        .unwrap_or_else(|e| format!("Error: {}", e));
4182                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4183                        .await;
4184                    return Ok(());
4185                }
4186                DirectAnswerKind::HostInspection => {
4187                    let topics = all_host_inspection_topics(&effective_user_input);
4188                    let response = if topics.len() >= 2 {
4189                        let mut combined = Vec::with_capacity(topics.len());
4190                        for topic in topics {
4191                            let args =
4192                                host_inspection_args_from_prompt(topic, &effective_user_input);
4193                            let output = crate::tools::host_inspect::inspect_host(&args)
4194                                .await
4195                                .unwrap_or_else(|e| format!("Error (topic {topic}): {e}"));
4196                            combined.push(format!("# Topic: {topic}\n{output}"));
4197                        }
4198                        combined.join("\n\n---\n\n")
4199                    } else {
4200                        let topic = preferred_host_inspection_topic(&effective_user_input)
4201                            .unwrap_or("summary");
4202                        let args = host_inspection_args_from_prompt(topic, &effective_user_input);
4203                        crate::tools::host_inspect::inspect_host(&args)
4204                            .await
4205                            .unwrap_or_else(|e| format!("Error: {e}"))
4206                    };
4207
4208                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4209                        .await;
4210                    return Ok(());
4211                }
4212                DirectAnswerKind::ArchitectSessionResetPlan => {
4213                    let plan = build_architect_session_reset_plan();
4214                    let response = plan.to_markdown();
4215                    let _ = crate::tools::plan::save_plan_handoff(&plan);
4216                    self.session_memory.current_plan = Some(plan);
4217                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4218                        .await;
4219                    return Ok(());
4220                }
4221                DirectAnswerKind::Help => {
4222                    let response = build_help_answer();
4223                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4224                        .await;
4225                    return Ok(());
4226                }
4227            }
4228        }
4229
4230        if matches!(
4231            self.workflow_mode,
4232            WorkflowMode::Ask | WorkflowMode::ReadOnly
4233        ) && looks_like_mutation_request(&effective_user_input)
4234        {
4235            let response = build_mode_redirect_answer(self.workflow_mode);
4236            self.history.push(ChatMessage::user(&effective_user_input));
4237            self.history.push(ChatMessage::assistant_text(&response));
4238            self.transcript.log_user(&transcript_user_input);
4239            self.transcript.log_agent(&response);
4240            for chunk in chunk_text(&response, 8) {
4241                if !chunk.is_empty() {
4242                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
4243                }
4244            }
4245            let _ = tx.send(InferenceEvent::Done).await;
4246            self.trim_history(80);
4247            self.refresh_session_memory();
4248            self.save_session();
4249            return Ok(());
4250        }
4251
4252        if user_input.trim() == "/think" {
4253            self.think_mode = Some(true);
4254            for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
4255                let _ = tx.send(InferenceEvent::Token(chunk)).await;
4256            }
4257            let _ = tx.send(InferenceEvent::Done).await;
4258            return Ok(());
4259        }
4260        if user_input.trim() == "/no_think" {
4261            self.think_mode = Some(false);
4262            for chunk in chunk_text(
4263                "Think mode: OFF — fast mode enabled (no chain-of-thought).",
4264                8,
4265            ) {
4266                let _ = tx.send(InferenceEvent::Token(chunk)).await;
4267            }
4268            let _ = tx.send(InferenceEvent::Done).await;
4269            return Ok(());
4270        }
4271
4272        // ── /pin: add file to active context ────────────────────────────────
4273        if user_input.trim_start().starts_with("/pin ") {
4274            let path = user_input.trim_start()[5..].trim();
4275            match std::fs::read_to_string(path) {
4276                Ok(content) => {
4277                    self.pinned_files
4278                        .write()
4279                        .await
4280                        .insert(path.to_string(), content);
4281                    let msg = format!(
4282                        "Pinned: {} — this file is now locked in model context.",
4283                        path
4284                    );
4285                    for chunk in chunk_text(&msg, 8) {
4286                        let _ = tx.send(InferenceEvent::Token(chunk)).await;
4287                    }
4288                }
4289                Err(e) => {
4290                    let _ = tx
4291                        .send(InferenceEvent::Error(format!(
4292                            "Failed to pin {}: {}",
4293                            path, e
4294                        )))
4295                        .await;
4296                }
4297            }
4298            let _ = tx.send(InferenceEvent::Done).await;
4299            return Ok(());
4300        }
4301
4302        // ── /unpin: remove file from active context ──────────────────────────
4303        if user_input.trim_start().starts_with("/unpin ") {
4304            let path = user_input.trim_start()[7..].trim();
4305            if self.pinned_files.write().await.remove(path).is_some() {
4306                let msg = format!("Unpinned: {} — file removed from active context.", path);
4307                for chunk in chunk_text(&msg, 8) {
4308                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
4309                }
4310            } else {
4311                let _ = tx
4312                    .send(InferenceEvent::Error(format!(
4313                        "File {} was not pinned.",
4314                        path
4315                    )))
4316                    .await;
4317            }
4318            let _ = tx.send(InferenceEvent::Done).await;
4319            return Ok(());
4320        }
4321
4322        // ── Normal processing ───────────────────────────────────────────────
4323
4324        // Ensure MCP is initialized and tools are discovered for this turn.
4325        if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
4326            if let Some(root) = extract_sovereign_scaffold_root(&effective_user_input) {
4327                if std::fs::create_dir_all(&root).is_ok() {
4328                    let targets = default_sovereign_scaffold_targets(&effective_user_input);
4329                    let _ = seed_sovereign_scaffold_files(&root, &targets);
4330                    let plan = build_sovereign_scaffold_handoff(&effective_user_input, &targets);
4331                    let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &plan);
4332                    let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
4333                    let _ = write_sovereign_handoff_markdown(&root, &effective_user_input, &plan);
4334                    self.pending_teleport_handoff = None;
4335                    self.latest_target_dir = Some(root.to_string_lossy().to_string());
4336                    let response = format!(
4337                        "Created the sovereign project root at `{}` and wrote a local handoff. Teleporting now so the next session can continue implementation inside that project.",
4338                        root.display()
4339                    );
4340                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4341                        .await;
4342                    return Ok(());
4343                }
4344            }
4345        }
4346
4347        let tiny_context_mode = self.engine.current_context_length() <= 8_192;
4348        let mut base_prompt = self.engine.build_system_prompt(
4349            self.snark,
4350            self.chaos,
4351            self.brief,
4352            self.professional,
4353            &self.tools,
4354            self.reasoning_history.as_deref(),
4355            None,
4356            &mcp_tools,
4357        );
4358        if !tiny_context_mode {
4359            if let Some(hint) = &config.context_hint {
4360                if !hint.trim().is_empty() {
4361                    let _ = write!(
4362                        base_prompt,
4363                        "\n\n# Project Context (from .hematite/settings.json)\n{}",
4364                        hint
4365                    );
4366                }
4367            }
4368            if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
4369                &crate::tools::file_ops::workspace_root(),
4370            ) {
4371                let _ = write!(base_prompt, "\n\n{}", profile_block);
4372            }
4373            if let Some(strategy_block) =
4374                crate::agent::workspace_profile::profile_strategy_prompt_block(
4375                    &crate::tools::file_ops::workspace_root(),
4376                )
4377            {
4378                let _ = write!(base_prompt, "\n\n{}", strategy_block);
4379            }
4380            // L1: inject hot-files block if available (persists across sessions via vein.db).
4381            if let Some(ref l1) = self.l1_context {
4382                let _ = write!(base_prompt, "\n\n{}", l1);
4383            }
4384            if let Some(ref repo_map_block) = self.repo_map {
4385                let _ = write!(base_prompt, "\n\n{}", repo_map_block);
4386            }
4387        }
4388        let grounded_trace_mode = intent.grounded_trace_mode
4389            || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
4390        let capability_mode =
4391            intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
4392        let toolchain_mode =
4393            intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
4394        // Embedding-based intent veto: when the keyword router says diagnostic,
4395        // ask nomic-embed whether the query is actually conversational/advisory.
4396        // Only fires when keyword routing would have triggered HOST INSPECTION MODE.
4397        // Falls back to the keyword result if the embed model is unavailable or slow.
4398        let host_inspection_mode = if intent.host_inspection_mode {
4399            let api_url = self.engine.base_url.clone();
4400            let query = effective_user_input.clone();
4401            let embed_class = tokio::time::timeout(
4402                std::time::Duration::from_millis(600),
4403                crate::agent::intent_embed::classify_intent(&query, &api_url),
4404            )
4405            .await
4406            .unwrap_or(crate::agent::intent_embed::IntentClass::Ambiguous);
4407            !matches!(
4408                embed_class,
4409                crate::agent::intent_embed::IntentClass::Advisory
4410            )
4411        } else {
4412            false
4413        };
4414        let maintainer_workflow_mode = intent.maintainer_workflow_mode
4415            || preferred_maintainer_workflow(&effective_user_input).is_some();
4416        let fix_plan_mode =
4417            preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
4418        let architecture_overview_mode = intent.architecture_overview_mode;
4419        let capability_needs_repo = intent.capability_needs_repo;
4420        let research_mode = (capability_needs_repo || !capability_mode)
4421            && intent.direct_answer.is_none()
4422            && intent.primary_class == QueryIntentClass::Research;
4423        let mut system_msg = build_system_with_corrections(
4424            &base_prompt,
4425            &self.correction_hints,
4426            &self.gpu_state,
4427            &self.git_state,
4428            &config,
4429        );
4430        if !tiny_context_mode && research_mode {
4431            system_msg.push_str(
4432                "\n\n# RESEARCH MODE\n\
4433                 This turn is an investigation into external technical information.\n\
4434                 Prioritize using the `research_web` tool to find the most current and authoritative data.\n\
4435                 When providing information, ground your answer in the search results and cite your sources if possible.\n\
4436                 If the user's question involves specific versions or recent releases (e.g., Rust compiler), use the web to verify the exact state.\n"
4437            );
4438        }
4439        if tiny_context_mode {
4440            system_msg.push_str(
4441                "\n\n# TINY CONTEXT TURN MODE\n\
4442                 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
4443            );
4444        }
4445        if !tiny_context_mode && grounded_trace_mode {
4446            system_msg.push_str(
4447                "\n\n# GROUNDED TRACE MODE\n\
4448                 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
4449                 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
4450                 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
4451                 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
4452                 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
4453                 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
4454                 Do not invent names such as synthetic channels or subsystems.\n\
4455                 If a detail is not verified from the code or tool output, say `uncertain`.\n\
4456                For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
4457            );
4458        }
4459        if !tiny_context_mode && capability_mode {
4460            // Consolidated: Capability instructions handled by prompt.rs
4461        }
4462        if !tiny_context_mode && toolchain_mode {
4463            // Consolidated: Toolchain instructions handled by prompt.rs
4464        }
4465        if !tiny_context_mode && host_inspection_mode {
4466            // Consolidated: Host Inspection rules handled by prompt.rs
4467        }
4468        if !tiny_context_mode && fix_plan_mode {
4469            system_msg.push_str(
4470                "\n\n# FIX PLAN MODE\n\
4471                 This turn is a workstation remediation question, not just a diagnosis question.\n\
4472                 Call `inspect_host` with `topic=fix_plan` first.\n\
4473                 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
4474                 Keep the answer grounded, stepwise, and approval-aware.\n"
4475            );
4476        }
4477        if !tiny_context_mode && maintainer_workflow_mode {
4478            system_msg.push_str(
4479                "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
4480                 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
4481                 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
4482                 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\
4483                 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"
4484            );
4485        }
4486        // Consolidated: Workspace Workflow rules handled by prompt.rs
4487
4488        if !tiny_context_mode && architecture_overview_mode {
4489            system_msg.push_str(
4490                "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
4491                 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
4492                 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\
4493                 Preserve grounded tool output rather than restyling it into a larger answer.\n"
4494            );
4495        }
4496
4497        // ── Inject Pinned Files (Context Locking) ───────────────────────────
4498        let _ = write!(
4499            system_msg,
4500            "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
4501            self.workflow_mode.label()
4502        );
4503        if tiny_context_mode {
4504            system_msg
4505                .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
4506        }
4507        if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
4508            system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
4509            system_msg.push_str(architect_handoff_contract());
4510            system_msg.push('\n');
4511        }
4512        if !tiny_context_mode && is_scaffold_request(&effective_user_input) {
4513            system_msg.push_str(scaffold_protocol());
4514        }
4515        if !tiny_context_mode {
4516            let workspace_root = crate::tools::file_ops::workspace_root();
4517            let skill_discovery =
4518                crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
4519            if let Some(bodies) = crate::agent::instructions::render_active_skill_bodies(
4520                &skill_discovery,
4521                &effective_user_input,
4522                8_000,
4523            ) {
4524                let _ = write!(system_msg, "\n\n{}", bodies);
4525            }
4526            // Inject any explicitly force-loaded skill from /skill <name>, then clear it.
4527            if let Some(forced_body) = self.pending_skill_inject.take() {
4528                let _ = write!(
4529                    system_msg,
4530                    "\n\n# Active Skill Instructions\n\n{}",
4531                    forced_body
4532                );
4533            }
4534        }
4535        if !tiny_context_mode && implement_current_plan {
4536            system_msg.push_str(
4537                "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
4538                 The user explicitly asked you to implement the current saved plan.\n\
4539                 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
4540                 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
4541                 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
4542                 If the saved plan explicitly calls for `research_web` or `fetch_docs`, do that research first, then return to the target files.\n\
4543                 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",
4544            );
4545            if let Some(plan) = self.session_memory.current_plan.as_ref() {
4546                if !plan.target_files.is_empty() {
4547                    system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
4548                    for path in &plan.target_files {
4549                        let _ = writeln!(system_msg, "- {}", path);
4550                    }
4551                }
4552            }
4553        }
4554        if !tiny_context_mode {
4555            let pinned = self.pinned_files.read().await;
4556            if !pinned.is_empty() {
4557                system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
4558                system_msg.push_str("The following files are locked in your active memory for prioritized reference.\n\n");
4559                for (path, content) in pinned.iter() {
4560                    let _ = write!(system_msg, "## FILE: {}\n```\n{}\n```\n\n", path, content);
4561                }
4562            }
4563        }
4564        if !tiny_context_mode {
4565            self.append_session_handoff(&mut system_msg);
4566        }
4567        // ── Inject TASK.md Visibility ────────────────────────────────────────
4568        let mut final_system_msg = if self.workflow_mode.is_chat() {
4569            self.build_chat_system_prompt()
4570        } else {
4571            system_msg
4572        };
4573
4574        if !tiny_context_mode
4575            && matches!(self.workflow_mode, WorkflowMode::Code | WorkflowMode::Auto)
4576        {
4577            let task_path = std::path::Path::new(".hematite/TASK.md");
4578            if task_path.exists() {
4579                if let Ok(content) = std::fs::read_to_string(task_path) {
4580                    let snippet = if content.lines().count() > 50 {
4581                        let mut s = String::with_capacity(50 * 80);
4582                        for (i, line) in content.lines().take(50).enumerate() {
4583                            if i > 0 {
4584                                s.push('\n');
4585                            }
4586                            s.push_str(line);
4587                        }
4588                        s + "\n... (truncated)"
4589                    } else {
4590                        content
4591                    };
4592                    final_system_msg.push_str("\n\n# CURRENT TASK STATUS (.hematite/TASK.md)\n");
4593                    final_system_msg.push_str("Update this file via `edit_file` to check off `[x]` items as you complete them.\n");
4594                    final_system_msg.push_str("```markdown\n");
4595                    final_system_msg.push_str(&snippet);
4596                    final_system_msg.push_str("\n```\n");
4597                }
4598            }
4599        }
4600
4601        // ── Inject user task list ────────────────────────────────────────────
4602        if !tiny_context_mode {
4603            let tasks = crate::agent::tasks::load();
4604            if let Some(block) = crate::agent::tasks::render_prompt_block(&tasks) {
4605                final_system_msg.push_str("\n\n");
4606                final_system_msg.push_str(&block);
4607            }
4608        }
4609
4610        // ── Inject shell history (once per session, non-chat modes) ──────────
4611        if !tiny_context_mode && !self.workflow_mode.is_chat() {
4612            if let Some(ref block) = self.shell_history_block {
4613                final_system_msg.push_str("\n\n");
4614                final_system_msg.push_str(block);
4615            }
4616        }
4617
4618        let system_msg = final_system_msg;
4619        if self.history.is_empty() || self.history[0].role != "system" {
4620            self.history.insert(0, ChatMessage::system(&system_msg));
4621        } else {
4622            self.history[0] = ChatMessage::system(&system_msg);
4623        }
4624
4625        // Ensure a clean state for the new turn.
4626        self.cancel_token
4627            .store(false, std::sync::atomic::Ordering::SeqCst);
4628
4629        // [Official Gemma-4 Spec] Purge reasoning history for new user turns.
4630        // History from previous turns must not be fed back into the prompt to prevent duplication.
4631        self.reasoning_history = None;
4632
4633        let is_gemma =
4634            crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
4635        let user_content = match self.think_mode {
4636            Some(true) => format!("/think\n{}", effective_user_input),
4637            Some(false) => format!("/no_think\n{}", effective_user_input),
4638            // For non-Gemma models (Qwen etc.) default to /think so the model uses
4639            // hybrid thinking — it decides how much reasoning each turn needs.
4640            // Gemma handles reasoning via <|think|> in the system prompt instead.
4641            // Chat mode and quick tool calls skip /think — fast direct answers.
4642            None if !is_gemma
4643                && !self.workflow_mode.is_chat()
4644                && !is_quick_tool_request(&effective_user_input) =>
4645            {
4646                format!("/think\n{}", effective_user_input)
4647            }
4648            None => effective_user_input.clone(),
4649        };
4650        if let Some(image) = user_turn.attached_image.as_ref() {
4651            let image_url =
4652                crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
4653                    .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
4654            self.history
4655                .push(ChatMessage::user_with_image(&user_content, &image_url));
4656        } else {
4657            self.history.push(ChatMessage::user(&user_content));
4658        }
4659        self.transcript.log_user(&transcript_user_input);
4660
4661        // Incremental re-index and Vein context injection. Ordinary chat mode
4662        // still skips repo-snippet noise, but docs-only workspaces and explicit
4663        // session-recall prompts should keep Vein memory available.
4664        let vein_docs_only = self.vein_docs_only_mode();
4665        let allow_vein_context = !self.workflow_mode.is_chat()
4666            || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
4667        let (vein_context, vein_paths) = if allow_vein_context {
4668            self.refresh_vein_index();
4669            let _ = tx
4670                .send(InferenceEvent::VeinStatus {
4671                    file_count: self.vein.file_count(),
4672                    embedded_count: self.vein.embedded_chunk_count(),
4673                    docs_only: vein_docs_only,
4674                })
4675                .await;
4676            match self.build_vein_context(&effective_user_input) {
4677                Some((ctx, paths)) => (Some(ctx), paths),
4678                None => (None, Vec::new()),
4679            }
4680        } else {
4681            (None, Vec::new())
4682        };
4683        // Reset Turn Diff Tracker for a fresh turn.
4684        {
4685            let mut tracker = self.diff_tracker.lock().await;
4686            tracker.reset();
4687        }
4688
4689        // Environment Heartbeat: Capture current toolchain state.
4690        let heartbeat = crate::agent::policy::ToolchainHeartbeat::capture();
4691        self.last_heartbeat = Some(heartbeat.clone());
4692
4693        if !vein_paths.is_empty() {
4694            let _ = tx
4695                .send(InferenceEvent::VeinContext { paths: vein_paths })
4696                .await;
4697        }
4698
4699        // Route: pick fast vs think model based on the complexity of this request.
4700        let routed_model = route_model(
4701            &effective_user_input,
4702            effective_fast.as_deref(),
4703            effective_think.as_deref(),
4704        )
4705        .map(|s| s.to_string());
4706
4707        let mut loop_intervention: Option<String> = None;
4708
4709        // ── Harness pre-run: multi-topic host inspection ─────────────────────
4710        // When the user asks for 2+ distinct inspect_host topics in one message,
4711        // run them all here and inject the combined results as a loop_intervention
4712        // so the model receives data instead of having to orchestrate tool calls.
4713        // This prevents the model from collapsing multiple topics into a generic
4714        // one, burning the tool loop budget, or retrying via shell.
4715        {
4716            let topics = all_host_inspection_topics(&effective_user_input);
4717            if topics.len() >= 2 {
4718                let _ = tx
4719                    .send(InferenceEvent::Thought(format!(
4720                        "Harness pre-run: {} host inspection topics detected — running all before model turn.",
4721                        topics.len()
4722                    )))
4723                    .await;
4724
4725                let topic_list = topics.join(", ");
4726                let mut combined = format!(
4727                    "## HARNESS PRE-RUN RESULTS\n\
4728                     The harness already ran inspect_host for the following topics: {topic_list}.\n\
4729                     Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
4730                );
4731
4732                let mut tool_calls = Vec::with_capacity(topics.len());
4733                let mut tool_msgs = Vec::with_capacity(topics.len());
4734
4735                for topic in &topics {
4736                    let call_id = format!("prerun_{topic}");
4737                    let mut args_val =
4738                        host_inspection_args_from_prompt(topic, &effective_user_input);
4739                    if let Some(obj) = args_val.as_object_mut() {
4740                        obj.insert("max_entries".to_string(), Value::from(20));
4741                    }
4742                    let _args_str = serde_json::to_string(&args_val).unwrap_or_default();
4743
4744                    tool_calls.push(crate::agent::types::ToolCallResponse {
4745                        id: call_id.clone(),
4746                        call_type: "function".to_string(),
4747                        function: crate::agent::types::ToolCallFn {
4748                            name: "inspect_host".to_string(),
4749                            arguments: args_val.clone(),
4750                        },
4751                        index: None,
4752                    });
4753
4754                    let label = format!("### inspect_host(topic=\"{topic}\")\n");
4755                    let _ = tx
4756                        .send(InferenceEvent::ToolCallStart {
4757                            id: call_id.clone(),
4758                            name: "inspect_host".to_string(),
4759                            args: format!("inspect host {topic}"),
4760                        })
4761                        .await;
4762
4763                    match crate::tools::host_inspect::inspect_host(&args_val).await {
4764                        Ok(out) => {
4765                            let _ = tx
4766                                .send(InferenceEvent::ToolCallResult {
4767                                    id: call_id.clone(),
4768                                    name: "inspect_host".to_string(),
4769                                    result: out.chars().take(300).collect::<String>() + "...",
4770                                    is_error: false,
4771                                })
4772                                .await;
4773                            combined.push_str(&label);
4774                            combined.push_str(&out);
4775                            combined.push_str("\n\n");
4776                            tool_msgs.push(ChatMessage::tool_result_for_model(
4777                                &call_id,
4778                                "inspect_host",
4779                                &out,
4780                                &self.engine.current_model(),
4781                            ));
4782                        }
4783                        Err(e) => {
4784                            let err_msg = format!("Error: {e}");
4785                            combined.push_str(&label);
4786                            combined.push_str(&err_msg);
4787                            combined.push_str("\n\n");
4788                            tool_msgs.push(ChatMessage::tool_result_for_model(
4789                                &call_id,
4790                                "inspect_host",
4791                                &err_msg,
4792                                &self.engine.current_model(),
4793                            ));
4794                        }
4795                    }
4796                }
4797
4798                // Add the simulated turn to history so the model sees it as context.
4799                self.history
4800                    .push(ChatMessage::assistant_tool_calls("", tool_calls));
4801                for msg in tool_msgs {
4802                    self.history.push(msg);
4803                }
4804
4805                loop_intervention = Some(combined);
4806            }
4807        }
4808
4809        // ── Research Pre-Run: force research_web for entity/knowledge queries ────
4810        // When the intent is classified as Research, the model often skips the
4811        // tool call and hallucinates from training data. To prevent this, we
4812        // execute research_web automatically and inject the results so the model
4813        // has grounded web data before it even starts generating.
4814        if loop_intervention.is_none() && research_mode {
4815            // Extract a clean search query from the user input.
4816            let search_query = extract_explicit_web_search_query(&effective_user_input)
4817                .unwrap_or_else(|| effective_user_input.trim().to_string());
4818
4819            let _ = tx
4820                .send(InferenceEvent::Thought(
4821                    "Research pre-run: executing search before model turn to ground the answer..."
4822                        .into(),
4823                ))
4824                .await;
4825
4826            let call_id = "prerun_research".to_string();
4827            let args = serde_json::json!({ "query": search_query });
4828
4829            let _ = tx
4830                .send(InferenceEvent::ToolCallStart {
4831                    id: call_id.clone(),
4832                    name: "research_web".to_string(),
4833                    args: format!("research_web: {}", search_query),
4834                })
4835                .await;
4836
4837            match crate::tools::research::execute_search(&args, config.searx_url.clone()).await {
4838                Ok(results)
4839                    if !results.is_empty() && !results.contains("No search results found") =>
4840                {
4841                    grounded_research_results = Some(results.clone());
4842                    let _ = tx
4843                        .send(InferenceEvent::ToolCallResult {
4844                            id: call_id.clone(),
4845                            name: "research_web".to_string(),
4846                            result: results.chars().take(300).collect::<String>() + "...",
4847                            is_error: false,
4848                        })
4849                        .await;
4850
4851                    loop_intervention = Some(format!(
4852                        "## RESEARCH PRE-RUN RESULTS\n\
4853                         The harness already ran `research_web` for your query.\n\
4854                         Use the search results above to answer the user's question with grounded, factual information.\n\
4855                         Do NOT re-run `research_web` unless you need additional detail.\n\
4856                         Do NOT hallucinate or guess — base your answer entirely on the search results.\n\n\
4857                         {}",
4858                        results
4859                    ));
4860                }
4861                Ok(_) | Err(_) => {
4862                    // Search returned empty or failed — let the model try on its own.
4863                    let _ = tx
4864                        .send(InferenceEvent::ToolCallResult {
4865                            id: call_id.clone(),
4866                            name: "research_web".to_string(),
4867                            result: "No results found — model will attempt its own search.".into(),
4868                            is_error: true,
4869                        })
4870                        .await;
4871                }
4872            }
4873        }
4874
4875        // ── Computation Integrity: nudge model toward run_code for precise math ──
4876        // When the query involves exact numeric computation (hashes, financial math,
4877        // statistics, date arithmetic, unit conversions, algorithmic checks), inject
4878        // a brief pre-turn reminder so the model reaches for run_code instead of
4879        // answering from training-data memory. Only fires when no harness pre-run
4880        // already set a loop_intervention.
4881        if loop_intervention.is_none() {
4882            if let Some(fix_ctx) = self.pending_fix_context.take() {
4883                loop_intervention = Some(format!(
4884                    "FIX MODE — The build is currently failing. Fix ONLY the error below. \
4885                     Do not refactor, add features, or touch unrelated code. \
4886                     After each edit call `verify_build` to check if the error is resolved. \
4887                     Stop as soon as the build is green.\n\n\
4888                     ## Current Build Error\n```\n{}\n```",
4889                    fix_ctx.trim()
4890                ));
4891            }
4892        }
4893
4894        if loop_intervention.is_none() && needs_github_ops(&effective_user_input) {
4895            loop_intervention = Some(
4896                "GITHUB TOOL NOTICE: This query is about GitHub (PRs, issues, CI runs, or checks). \
4897                 Use the `github_ops` tool — never call `gh` via `shell`. \
4898                 For a quick overview, try `/pr` (PR status), `/ci` (CI status), or `/issue` (issues). \
4899                 The model should call `github_ops` with the appropriate `action` field."
4900                    .to_string(),
4901            );
4902        }
4903
4904        if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
4905            loop_intervention = Some(
4906                "COMPUTATION INTEGRITY NOTICE: This query involves precise numeric computation. \
4907                 Do NOT answer from training-data memory — memory answers for math are guesses. \
4908                 Use `run_code` to compute the real result and return the actual output. \
4909                 IMPORTANT: the `run_code` tool defaults to JavaScript (Deno). \
4910                 If you write Python code, you MUST pass `language: \"python\"` explicitly. \
4911                 If you write JavaScript/TypeScript, omit the language field or pass `language: \"javascript\"`. \
4912                 Write the code, run it, return the result."
4913                    .to_string(),
4914            );
4915        }
4916
4917        // ── Native Tool Mandate: nudge model toward create_directory/write_file for local mutations ──
4918        if loop_intervention.is_none() && intent.surgical_filesystem_mode {
4919            loop_intervention = Some(
4920                "NATIVE TOOL MANDATE: Your request involves local directory or file creation. \
4921                 You MUST use Hematite's native surgical tools (`create_directory`, `write_file`, `update_file`, `patch_hunk`). \
4922                 External `mcp__filesystem__*` mutation tools are BLOCKED for these actions and will fail. \
4923                 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for 100% path accuracy."
4924                    .to_string(),
4925            );
4926        }
4927
4928        // ── Auto-Architect: complex scaffold requests in /auto get a plan-first nudge ──
4929        // When the user asks for a multi-file build in /auto mode, instruct the model
4930        // to draft a PLAN.md blueprint first. The plan_drafted_this_turn gate at the
4931        // end of run_turn will then fire the Y/N approval and chain into implementation.
4932        if loop_intervention.is_none()
4933            && self.workflow_mode == WorkflowMode::Auto
4934            && is_scaffold_request(&effective_user_input)
4935            && !implement_current_plan
4936        {
4937            loop_intervention = Some(
4938                "AUTO-ARCHITECT: This request involves building multiple files (a scaffold). \
4939                 Before implementing, draft a concise blueprint to `.hematite/PLAN.md` using `write_file`. \
4940                 The blueprint should list:\n\
4941                 1. The target directory path\n\
4942                 2. Each file to create (with a one-line description of its purpose)\n\
4943                 3. Key design decisions (e.g. color scheme, layout approach)\n\n\
4944                 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for path accuracy.\n\
4945                 After writing the PLAN.md, respond with a brief summary of what you planned. \
4946                 Do NOT start implementing yet — just write the plan."
4947                    .to_string(),
4948            );
4949        }
4950
4951        let mut implementation_started = false;
4952        let mut plan_drafted_this_turn = false;
4953        let mut non_mutating_plan_steps = 0usize;
4954        let non_mutating_plan_soft_cap = 5usize;
4955        let non_mutating_plan_hard_cap = 8usize;
4956        let mut overview_runtime_trace: Option<String> = None;
4957
4958        // Safety cap – never spin forever on a broken model.
4959        let max_iters = 25;
4960        let mut consecutive_errors = 0;
4961        let mut empty_cleaned_nudges = 0u8;
4962        let mut first_iter = true;
4963        let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
4964        // Track identical tool results within this turn to detect logical loops.
4965        let _result_counts: std::collections::HashMap<String, usize> =
4966            std::collections::HashMap::new();
4967        // Track the count of identical (name, args) calls to detect infinite tool loops.
4968        let mut repeat_counts: std::collections::HashMap<String, usize> =
4969            std::collections::HashMap::with_capacity(8);
4970        let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
4971            std::collections::HashMap::with_capacity(8);
4972        let mut successful_read_targets: std::collections::HashSet<String> =
4973            std::collections::HashSet::with_capacity(8);
4974        // (path, offset) pairs — catches repeated reads at the same non-zero offset.
4975        let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
4976            std::collections::HashSet::with_capacity(8);
4977        let mut successful_grep_targets: std::collections::HashSet<String> =
4978            std::collections::HashSet::with_capacity(8);
4979        let mut no_match_grep_targets: std::collections::HashSet<String> =
4980            std::collections::HashSet::with_capacity(8);
4981        let mut broad_grep_targets: std::collections::HashSet<String> =
4982            std::collections::HashSet::with_capacity(8);
4983        let mut sovereign_task_root: Option<String> = None;
4984        let mut sovereign_scaffold_targets: std::collections::BTreeSet<String> =
4985            std::collections::BTreeSet::new();
4986        let mut turn_mutated_paths: std::collections::BTreeSet<String> =
4987            std::collections::BTreeSet::new();
4988        let mut mutation_counts_by_path: std::collections::HashMap<String, usize> =
4989            std::collections::HashMap::with_capacity(4);
4990        let mut frontend_polish_intervention_emitted = false;
4991        let mut visible_closeout_emitted = false;
4992
4993        // Track the index of the message that started THIS turn, so compaction doesn't summarize it.
4994        let mut turn_anchor = self.history.len().saturating_sub(1);
4995
4996        // ── Pre-turn compaction (Codex-style: PreTurn phase) ────────────────
4997        // If context is already overloaded before inference starts, compact now.
4998        // This prevents the model from seeing a 90%+ full prompt on the first call.
4999        {
5000            let context_length = self.engine.current_context_length();
5001            let vram_ratio = self.gpu_state.ratio();
5002            if compaction::should_compact(&self.history, context_length, vram_ratio) {
5003                let _ = tx
5004                    .send(InferenceEvent::Thought(
5005                        "Pre-turn compaction: context pressure detected — compacting history before inference.".into(),
5006                    ))
5007                    .await;
5008                if self
5009                    .compact_history_if_needed(&tx, Some(turn_anchor))
5010                    .await?
5011                {
5012                    // After compaction, history is [system, summary, user, ...].
5013                    // Recalculate the anchor so the in-loop compaction doesn't misfire.
5014                    turn_anchor = self
5015                        .history
5016                        .iter()
5017                        .rposition(|m| m.role == "user")
5018                        .unwrap_or(self.history.len().saturating_sub(1));
5019                }
5020            }
5021        }
5022
5023        // Prevent Windows from sleeping during inference/tool execution.
5024        // Dropped automatically when the turn completes.
5025        let _sleep_guard = crate::ui::sleep_inhibitor::SleepInhibitor::acquire();
5026
5027        // ── Context budget ledger — snapshot tokens before this turn ────────
5028        let (budget_input_start, budget_output_start) = {
5029            let econ = self
5030                .engine
5031                .economics
5032                .lock()
5033                .unwrap_or_else(|p| p.into_inner());
5034            (econ.input_tokens, econ.output_tokens)
5035        };
5036        // Estimate existing history size before this turn (excludes system prompt).
5037        let budget_history_est: usize = self
5038            .history
5039            .iter()
5040            .take(turn_anchor)
5041            .map(crate::agent::inference::estimate_message_tokens)
5042            .sum();
5043        // Accumulates per-tool result costs (chars / 4) during the turn.
5044        let mut budget_tool_costs: Vec<crate::agent::economics::ToolCost> = Vec::with_capacity(8);
5045
5046        for _iter in 0..max_iters {
5047            let context_prep_start = tokio::time::Instant::now();
5048            let mut mutation_occurred = false;
5049            // Priority Check: External Cancellation (via Esc key in TUI)
5050            if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
5051                self.cancel_token
5052                    .store(false, std::sync::atomic::Ordering::SeqCst);
5053                let _ = tx
5054                    .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
5055                    .await;
5056                let _ = tx.send(InferenceEvent::Done).await;
5057                return Ok(());
5058            }
5059
5060            // ── Intelligence Surge: Proactive Compaction Check ──────────────────────
5061            if self
5062                .compact_history_if_needed(&tx, Some(turn_anchor))
5063                .await?
5064            {
5065                // After compaction, history is [system, summary, turn_anchor, ...]
5066                // The new turn_anchor is index 2.
5067                turn_anchor = 2;
5068            }
5069
5070            // On the first iteration inject Vein context into the system message.
5071            // Subsequent iterations use the plain slice — tool results are now in
5072            // history so Vein context would be redundant.
5073            let inject_vein = first_iter && !implement_current_plan;
5074            let messages = if implement_current_plan {
5075                first_iter = false;
5076                self.context_window_slice_from(turn_anchor)
5077            } else {
5078                first_iter = false;
5079                self.context_window_slice()
5080            };
5081
5082            // Use the canonical system prompt from history[0] which was built
5083            // by InferenceEngine::build_system_prompt() + build_system_with_corrections()
5084            // and includes GPU state, git context, permissions, and instruction files.
5085            let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
5086                // Gemma 4 handles multiple system messages natively.
5087                // Standard models (Qwen, etc.) reject a second system message — merge into history[0].
5088                if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
5089                    let mut msgs = vec![self.history[0].clone()];
5090                    msgs.push(ChatMessage::system(&intervention));
5091                    msgs
5092                } else {
5093                    let merged =
5094                        format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
5095                    vec![ChatMessage::system(&merged)]
5096                }
5097            } else {
5098                vec![self.history[0].clone()]
5099            };
5100
5101            // Inject Vein context into the system message on the first iteration.
5102            // Vein results are merged in the same way as loop_intervention so standard
5103            // models (Qwen etc.) only ever see one system message.
5104            if inject_vein {
5105                if let Some(ctx) = vein_context.as_deref() {
5106                    if crate::agent::inference::is_hematite_native_model(
5107                        &self.engine.current_model(),
5108                    ) {
5109                        prompt_msgs.push(ChatMessage::system(ctx));
5110                    } else {
5111                        let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
5112                        prompt_msgs[0] = ChatMessage::system(&merged);
5113                    }
5114                }
5115            }
5116            if let Some(root) = sovereign_task_root.as_ref() {
5117                let sovereign_root_instruction = format!(
5118                    "EFFECTIVE TASK ROOT: This sovereign scaffold turn is now rooted at:\n\
5119                     `{root}`\n\n\
5120                     Treat that directory as the active project root for the rest of this turn. \
5121                     All reads, writes, verification, and summaries must stay scoped to that root. \
5122                     Ignore unrelated repo context such as `./src` unless the user explicitly asks about it. \
5123                     Keep building within this sovereign root instead of reasoning from the original workspace."
5124                );
5125                if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
5126                    prompt_msgs.push(ChatMessage::system(&sovereign_root_instruction));
5127                } else {
5128                    let merged = format!(
5129                        "{}\n\n{}",
5130                        prompt_msgs[0].content.as_str(),
5131                        sovereign_root_instruction
5132                    );
5133                    prompt_msgs[0] = ChatMessage::system(&merged);
5134                }
5135            }
5136            prompt_msgs.extend(messages);
5137            if let Some(budget_note) =
5138                enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
5139            {
5140                self.emit_operator_checkpoint(
5141                    &tx,
5142                    OperatorCheckpointState::BudgetReduced,
5143                    budget_note,
5144                )
5145                .await;
5146                let recipe = plan_recovery(
5147                    RecoveryScenario::PromptBudgetPressure,
5148                    &self.recovery_context,
5149                );
5150                self.emit_recovery_recipe_summary(
5151                    &tx,
5152                    recipe.recipe.scenario.label(),
5153                    compact_recovery_plan_summary(&recipe),
5154                )
5155                .await;
5156            }
5157            self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
5158                .await;
5159
5160            let turn_tools = if yolo
5161                || (explicit_search_request && grounded_research_results.is_some())
5162            {
5163                // FORCE NLG ONLY: Hide all tools to ensure a plain text summary.
5164                Vec::new()
5165            } else if intent.sovereign_mode {
5166                self.tools
5167                    .iter()
5168                    .filter(|t| {
5169                        t.function.name != "shell" && t.function.name != "run_workspace_workflow"
5170                    })
5171                    .cloned()
5172                    .collect::<Vec<_>>()
5173            } else {
5174                self.tools.clone()
5175            };
5176
5177            let context_prep_ms = context_prep_start.elapsed().as_millis();
5178            let inference_start = tokio::time::Instant::now();
5179
5180            let explicit_search_synthesis = explicit_search_request
5181                && grounded_research_results.is_some()
5182                && turn_tools.is_empty();
5183
5184            let call_result = if explicit_search_synthesis {
5185                match tokio::time::timeout(
5186                    tokio::time::Duration::from_secs(20),
5187                    self.engine
5188                        .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref()),
5189                )
5190                .await
5191                {
5192                    Ok(result) => result,
5193                    Err(_) => Err(
5194                        "explicit_search_synthesis_timeout: grounded research summary took too long to complete"
5195                            .to_string(),
5196                    ),
5197                }
5198            } else {
5199                self.engine
5200                    .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref())
5201                    .await
5202            };
5203
5204            let (mut text, mut tool_calls, usage, finish_reason) = match call_result {
5205                Ok(result) => result,
5206                Err(e) => {
5207                    if explicit_search_synthesis
5208                        && (e.contains("explicit_search_synthesis_timeout")
5209                            || e.contains("provider_degraded")
5210                            || e.contains("empty response"))
5211                    {
5212                        if let Some(results) = grounded_research_results.as_deref() {
5213                            let response = build_research_provider_fallback(results);
5214                            self.history.push(ChatMessage::assistant_text(&response));
5215                            self.transcript.log_agent(&response);
5216                            let _ = tx
5217                                .send(InferenceEvent::Thought(
5218                                    "Search synthesis stalled; returning a grounded fallback summary from the fetched results."
5219                                        .into(),
5220                                ))
5221                                .await;
5222                            for chunk in chunk_text(&response, 8) {
5223                                let _ = tx.send(InferenceEvent::Token(chunk)).await;
5224                            }
5225                            let _ = tx.send(InferenceEvent::Done).await;
5226                            return Ok(());
5227                        }
5228                    }
5229
5230                    let class = classify_runtime_failure(&e);
5231                    if should_retry_runtime_failure(class)
5232                        && self.recovery_context.consume_transient_retry()
5233                    {
5234                        let label = match class {
5235                            RuntimeFailureClass::ProviderDegraded => "provider_degraded",
5236                            _ => "empty_model_response",
5237                        };
5238                        self.transcript.log_system(&format!(
5239                            "Automatic provider recovery triggered: {}",
5240                            e.trim()
5241                        ));
5242                        self.emit_recovery_recipe_summary(
5243                            &tx,
5244                            label,
5245                            compact_runtime_recovery_summary(class),
5246                        )
5247                        .await;
5248                        let _ = tx
5249                            .send(InferenceEvent::ProviderStatus {
5250                                state: ProviderRuntimeState::Recovering,
5251                                summary: compact_runtime_recovery_summary(class).into(),
5252                            })
5253                            .await;
5254                        self.emit_operator_checkpoint(
5255                            &tx,
5256                            OperatorCheckpointState::RecoveringProvider,
5257                            compact_runtime_recovery_summary(class),
5258                        )
5259                        .await;
5260                        continue;
5261                    }
5262
5263                    if explicit_search_request
5264                        && matches!(
5265                            class,
5266                            RuntimeFailureClass::ProviderDegraded
5267                                | RuntimeFailureClass::EmptyModelResponse
5268                        )
5269                    {
5270                        if let Some(results) = grounded_research_results.as_deref() {
5271                            let response = build_research_provider_fallback(results);
5272                            self.history.push(ChatMessage::assistant_text(&response));
5273                            self.transcript.log_agent(&response);
5274                            for chunk in chunk_text(&response, 8) {
5275                                let _ = tx.send(InferenceEvent::Token(chunk)).await;
5276                            }
5277                            let _ = tx.send(InferenceEvent::Done).await;
5278                            return Ok(());
5279                        }
5280                    }
5281
5282                    self.emit_runtime_failure(&tx, class, &e).await;
5283                    break;
5284                }
5285            };
5286            let inference_ms = inference_start.elapsed().as_millis();
5287            let execution_start = tokio::time::Instant::now();
5288            self.emit_provider_live(&tx).await;
5289
5290            // ── LOOP GUARD: Reasoning Collapse Detection ──────────────────────────
5291            // If the model returns no text AND no tool calls, but has a massive
5292            // block of hidden reasoning (often seen as infinite newlines in small models),
5293            // trigger a safety stop to prevent token drain.
5294            if text.is_none() && tool_calls.is_none() {
5295                if let Some(reasoning) = usage.as_ref().and_then(|u| {
5296                    if u.completion_tokens > 2000 {
5297                        Some(u.completion_tokens)
5298                    } else {
5299                        None
5300                    }
5301                }) {
5302                    self.emit_operator_checkpoint(
5303                        &tx,
5304                        OperatorCheckpointState::BlockedToolLoop,
5305                        format!(
5306                            "Reasoning collapse detected ({} tokens of empty output).",
5307                            reasoning
5308                        ),
5309                    )
5310                    .await;
5311                    break;
5312                }
5313            }
5314
5315            // Update TUI token counter with actual usage from LM Studio.
5316            if let Some(ref u) = usage {
5317                let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
5318            }
5319
5320            // Fallback safety net: if native tool markup leaked past the inference-layer
5321            // extractor, recover it here instead of treating it as plain assistant text.
5322            if tool_calls
5323                .as_ref()
5324                .map(|calls| calls.is_empty())
5325                .unwrap_or(true)
5326            {
5327                if let Some(raw_text) = text.as_deref() {
5328                    let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
5329                    if !native_calls.is_empty() {
5330                        tool_calls = Some(native_calls);
5331                        let stripped =
5332                            crate::agent::inference::strip_native_tool_call_text(raw_text);
5333                        text = if stripped.trim().is_empty() {
5334                            None
5335                        } else {
5336                            Some(stripped)
5337                        };
5338                    }
5339                }
5340            }
5341
5342            // Treat empty tool_calls arrays (Some(vec![])) the same as None –
5343            // the model returned text only; an empty array causes an infinite loop.
5344            let tool_calls = tool_calls.filter(|c| !c.is_empty());
5345            let near_context_ceiling = usage
5346                .as_ref()
5347                .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
5348                .unwrap_or(false);
5349
5350            if let Some(calls) = tool_calls {
5351                let (calls, prune_trace_note) =
5352                    prune_architecture_trace_batch(calls, architecture_overview_mode);
5353                if let Some(note) = prune_trace_note {
5354                    let _ = tx.send(InferenceEvent::Thought(note)).await;
5355                }
5356
5357                let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
5358                    calls,
5359                    self.workflow_mode.is_read_only(),
5360                    architecture_overview_mode,
5361                );
5362                if let Some(note) = prune_bloat_note {
5363                    let _ = tx.send(InferenceEvent::Thought(note)).await;
5364                }
5365
5366                let (calls, prune_note) = prune_authoritative_tool_batch(
5367                    calls,
5368                    grounded_trace_mode,
5369                    &effective_user_input,
5370                );
5371                if let Some(note) = prune_note {
5372                    let _ = tx.send(InferenceEvent::Thought(note)).await;
5373                }
5374
5375                let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
5376                if let Some(note) = prune_redir_note {
5377                    let _ = tx.send(InferenceEvent::Thought(note)).await;
5378                }
5379
5380                let (calls, batch_note) = order_batch_reads_first(calls);
5381                if let Some(note) = batch_note {
5382                    let _ = tx.send(InferenceEvent::Thought(note)).await;
5383                }
5384
5385                if let Some(repeated_path) = calls
5386                    .iter()
5387                    .filter_map(|c| repeated_read_target(&c.function))
5388                    .find(|path| successful_read_targets.contains(path))
5389                {
5390                    let repeated_path = repeated_path.to_string();
5391
5392                    let err_msg = format!(
5393                        "Read discipline: You already read `{}` recently. Use `inspect_lines` on a specific window or `grep_files` to find content, then continue with your edit.",
5394                        repeated_path
5395                    );
5396                    let _ = tx
5397                        .clone()
5398                        .send(InferenceEvent::Token(format!("\n⚠️ {}\n", err_msg)))
5399                        .await;
5400                    let _ = tx
5401                        .clone()
5402                        .send(InferenceEvent::Thought(format!(
5403                            "Intervention: {}",
5404                            err_msg
5405                        )))
5406                        .await;
5407
5408                    // BREAK THE SILENT LOOP: Push hard errors for these tool calls individually.
5409                    // This forces the LLM to see the result and pivot in its next turn.
5410                    for call in &calls {
5411                        self.history.push(ChatMessage::tool_result_for_model(
5412                            &call.id,
5413                            &call.function.name,
5414                            &err_msg,
5415                            &self.engine.current_model(),
5416                        ));
5417                    }
5418                    self.emit_done_events(&tx).await;
5419                    return Ok(());
5420                }
5421
5422                if capability_mode
5423                    && !capability_needs_repo
5424                    && calls
5425                        .iter()
5426                        .all(|c| is_capability_probe_tool(&c.function.name))
5427                {
5428                    loop_intervention = Some(
5429                        "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
5430                         Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
5431                         Do not mention raw `mcp__*` names unless they are active and directly relevant."
5432                            .to_string(),
5433                    );
5434                    let _ = tx.clone()
5435                        .send(InferenceEvent::Thought(
5436                            "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
5437                                .into(),
5438                        ))
5439                        .await;
5440                    continue;
5441                }
5442
5443                // VOCAL AGENT: If the model provided reasoning alongside tools,
5444                // stream it to the SPECULAR panel now using the hardened extraction.
5445                let raw_content = text.as_deref().unwrap_or(" ");
5446
5447                if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
5448                    let _ = tx
5449                        .clone()
5450                        .send(InferenceEvent::Thought(thought.clone()))
5451                        .await;
5452                    // Reasoning is silent (hidden in SPECULAR only).
5453                    self.reasoning_history = Some(thought);
5454                }
5455
5456                // [Gemma-4 Protocol] Keep raw content (including thoughts) during tool loops.
5457                // Thoughts are only stripped before the 'final' user turn.
5458                let stored_tool_call_content = if implement_current_plan {
5459                    cap_output(raw_content, 1200)
5460                } else {
5461                    raw_content.to_string()
5462                };
5463                self.history.push(ChatMessage::assistant_tool_calls(
5464                    &stored_tool_call_content,
5465                    calls.clone(),
5466                ));
5467
5468                // ── LAYER 4: Parallel Tool Orchestration (Batching) ────────────────────
5469                let mut results = Vec::with_capacity(calls.len());
5470                let gemma4_model =
5471                    crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
5472                let latest_user_prompt = self.latest_user_prompt();
5473                let mut seen_call_keys = std::collections::HashSet::new();
5474                let mut deduped_calls = Vec::with_capacity(calls.len());
5475                for call in calls.clone() {
5476                    let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
5477                        &call.function.name,
5478                        &call.function.arguments,
5479                        gemma4_model,
5480                        latest_user_prompt,
5481                    );
5482
5483                    // Authoritative Diff Tracking: Capture baseline before mutation.
5484                    if crate::agent::policy::is_destructive_tool(&normalized_name) {
5485                        if let Some(path) = crate::agent::policy::tool_path_argument(
5486                            &normalized_name,
5487                            &normalized_args,
5488                        ) {
5489                            let tracker = self.diff_tracker.clone();
5490                            tokio::spawn(async move {
5491                                let mut guard = tracker.lock().await;
5492                                guard.on_file_access(std::path::Path::new(&path));
5493                            });
5494                        }
5495                    }
5496
5497                    // --- HALLUCINATION SANITIZER ---
5498                    if normalized_name == "shell" || normalized_name == "run_workspace_workflow" {
5499                        let cmd_val = normalized_args
5500                            .get("command")
5501                            .or_else(|| normalized_args.get("workflow"));
5502
5503                        if let Some(cmd) = cmd_val.and_then(|v| v.as_str()) {
5504                            if cfg!(windows)
5505                                && (cmd.contains("/dev/")
5506                                    || cmd.contains("/etc/")
5507                                    || cmd.contains("/var/"))
5508                            {
5509                                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.";
5510                                let _ = tx
5511                                    .clone()
5512                                    .send(InferenceEvent::Token(format!("\n🚨 {}\n", err_msg)))
5513                                    .await;
5514                                let _ = tx
5515                                    .clone()
5516                                    .send(InferenceEvent::Thought(format!(
5517                                        "Panic blocked: {}",
5518                                        err_msg
5519                                    )))
5520                                    .await;
5521
5522                                // BREAK THE COLLAPSE: Push hard errors for all tool calls in this batch and end turn.
5523                                let mut err_results = Vec::with_capacity(calls.len());
5524                                for c in &calls {
5525                                    err_results.push(ChatMessage::tool_result_for_model(
5526                                        &c.id,
5527                                        &c.function.name,
5528                                        err_msg,
5529                                        &self.engine.current_model(),
5530                                    ));
5531                                }
5532                                for res in err_results {
5533                                    self.history.push(res);
5534                                }
5535                                self.emit_done_events(&tx).await;
5536                                return Ok(());
5537                            }
5538
5539                            if is_natural_language_hallucination(cmd) {
5540                                let err_msg = format!(
5541                                    "HALLUCINATION BLOCKED: You tried to pass natural language ('{}') into a command field. \
5542                                     Commands must be literal executables (e.g. `npm install`, `mkdir path`). \
5543                                     Use the correct surgical tool (like `create_directory`) instead of overthinking.",
5544                                    cmd
5545                                );
5546                                let _ = tx
5547                                    .send(InferenceEvent::Thought(format!(
5548                                        "Sanitizer error: {}",
5549                                        err_msg
5550                                    )))
5551                                    .await;
5552                                results.push(ToolExecutionOutcome {
5553                                    call_id: call.id.clone(),
5554                                    tool_name: normalized_name.clone(),
5555                                    args: normalized_args.clone(),
5556                                    output: err_msg,
5557                                    is_error: true,
5558                                    blocked_by_policy: false,
5559                                    msg_results: Vec::new(),
5560                                    latest_target_dir: None,
5561                                    plan_drafted_this_turn: false,
5562                                    parsed_plan_handoff: None,
5563                                });
5564                                continue;
5565                            }
5566                        }
5567                    }
5568
5569                    let key = canonical_tool_call_key(&normalized_name, &normalized_args);
5570                    if seen_call_keys.insert(key) {
5571                        let repeat_guard_exempt = matches!(
5572                            normalized_name.as_str(),
5573                            "verify_build" | "git_commit" | "git_push"
5574                        );
5575                        if !repeat_guard_exempt {
5576                            if let Some(cached) = completed_tool_cache
5577                                .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
5578                            {
5579                                let _ = tx
5580                                    .send(InferenceEvent::Thought(
5581                                        "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
5582                                            .to_string(),
5583                                    ))
5584                                    .await;
5585                                loop_intervention = Some(format!(
5586                                    "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.",
5587                                    cached.tool_name
5588                                ));
5589                                continue;
5590                            }
5591                        }
5592                        deduped_calls.push(call);
5593                    } else {
5594                        let _ = tx
5595                            .send(InferenceEvent::Thought(
5596                                "Duplicate tool call skipped: identical built-in invocation already ran this turn."
5597                                    .to_string(),
5598                            ))
5599                            .await;
5600                    }
5601                }
5602
5603                // Phase 5: Calculate predictive token budget for this turn's tool responses.
5604                // We reserve 3000 tokens for the final summary and the bootstrap context of the next turn.
5605                let total_used = usage.as_ref().map(|u| u.total_tokens).unwrap_or(0);
5606                let ctx_len = self.engine.current_context_length();
5607                let remaining = ctx_len.saturating_sub(total_used);
5608                let tool_budget = remaining.saturating_sub(3000);
5609                let budget_per_call = if deduped_calls.is_empty() {
5610                    0
5611                } else {
5612                    tool_budget / deduped_calls.len().max(1)
5613                };
5614
5615                // Partition tool calls: Parallel Read vs Serial Mutating
5616                let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
5617                    .into_iter()
5618                    .partition(|c| is_parallel_safe(&c.function.name));
5619
5620                // 1. Concurrent Execution (ParallelRead)
5621                if !parallel_calls.is_empty() {
5622                    let mut tasks = Vec::with_capacity(parallel_calls.len());
5623                    for call in parallel_calls {
5624                        let tx_clone = tx.clone();
5625                        let config_clone = config.clone();
5626                        // Carry the real call ID into the outcome
5627                        let call_with_id = call.clone();
5628                        tasks.push(self.process_tool_call(
5629                            call_with_id.function,
5630                            config_clone,
5631                            yolo,
5632                            tx_clone,
5633                            call_with_id.id,
5634                            budget_per_call,
5635                        ));
5636                    }
5637                    // Wait for all read-only tasks to complete simultaneously.
5638                    results.extend(futures::future::join_all(tasks).await);
5639                }
5640
5641                // 2. Sequential Execution (SerialMutating)
5642                let mut sovereign_bootstrap_complete = false;
5643
5644                for call in serial_calls {
5645                    let outcome = self
5646                        .process_tool_call(
5647                            call.function,
5648                            config.clone(),
5649                            yolo,
5650                            tx.clone(),
5651                            call.id,
5652                            budget_per_call,
5653                        )
5654                        .await;
5655
5656                    if !outcome.is_error {
5657                        let tool_name = outcome.tool_name.as_str();
5658                        if matches!(
5659                            tool_name,
5660                            "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5661                        ) {
5662                            if let Some(target) = action_target_path(tool_name, &outcome.args) {
5663                                let normalized_path = normalize_workspace_path(&target);
5664                                let rewrite_count = mutation_counts_by_path
5665                                    .entry(normalized_path.clone())
5666                                    .and_modify(|count| *count += 1)
5667                                    .or_insert(1);
5668
5669                                let is_frontend_asset = [
5670                                    ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx", ".vue",
5671                                    ".svelte",
5672                                ]
5673                                .iter()
5674                                .any(|ext| normalized_path.ends_with(ext));
5675
5676                                if is_frontend_asset && *rewrite_count >= 3 {
5677                                    frontend_polish_intervention_emitted = true;
5678                                    loop_intervention = Some(format!(
5679                                        "REWRITE LIMIT REACHED. You have updated `{}` {} times this turn. To prevent reasoning collapse, further rewrites to this file are blocked. \
5680                                         Please UPDATE `.hematite/TASK.md` to check off these completed steps, and response with a concise engineering summary of the implementation status.",
5681                                        normalized_path, rewrite_count
5682                                    ));
5683                                    results.push(outcome);
5684                                    let _ = tx.send(InferenceEvent::Thought("Frontend rewrite guard: block reached — prompting for task update and summary.".to_string())).await;
5685                                    break; // Terminate this turn's tool execution immediately.
5686                                } else if !frontend_polish_intervention_emitted
5687                                    && is_frontend_asset
5688                                    && *rewrite_count >= 2
5689                                {
5690                                    frontend_polish_intervention_emitted = true;
5691                                    loop_intervention = Some(format!(
5692                                        "STOP REWRITING. You have already written `{}` {} times. The current version is sufficient as a foundation. \
5693                                         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.",
5694                                        normalized_path, rewrite_count
5695                                    ));
5696                                    results.push(outcome);
5697                                    let _ = tx.send(InferenceEvent::Thought("Frontend polish guard: repeated rewrite detected; prompting for progress log and next steps.".to_string())).await;
5698                                    break; // Terminate this turn's tool execution immediately.
5699                                }
5700                            }
5701                        }
5702                    }
5703
5704                    if !outcome.is_error
5705                        && intent.sovereign_mode
5706                        && is_scaffold_request(&effective_user_input)
5707                        && outcome.latest_target_dir.is_some()
5708                    {
5709                        sovereign_bootstrap_complete = true;
5710                    }
5711                    results.push(outcome);
5712                    if sovereign_bootstrap_complete {
5713                        let _ = tx
5714                            .send(InferenceEvent::Thought(
5715                                "Sovereign scaffold bootstrap complete: stopping this session after root setup so the resumed session can continue inside the new project."
5716                                    .to_string(),
5717                            ))
5718                            .await;
5719                        break;
5720                    }
5721                }
5722
5723                let execution_ms = execution_start.elapsed().as_millis();
5724                let _ = tx
5725                    .send(InferenceEvent::TurnTiming {
5726                        context_prep_ms,
5727                        inference_ms,
5728                        execution_ms,
5729                    })
5730                    .await;
5731
5732                // 3. Collate Messages into History & UI
5733                let mut authoritative_tool_output: Option<String> = None;
5734                let mut blocked_policy_output: Option<String> = None;
5735                let mut recoverable_policy_intervention: Option<String> = None;
5736                let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
5737                let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
5738                    None;
5739                for res in results {
5740                    let call_id = res.call_id.clone();
5741                    let tool_name = res.tool_name.clone();
5742                    let final_output = res.output.clone();
5743                    let is_error = res.is_error;
5744                    for msg in res.msg_results {
5745                        self.history.push(msg);
5746                    }
5747
5748                    // Update State for Verification Loop
5749                    if let Some(path) = res.latest_target_dir {
5750                        if intent.sovereign_mode && sovereign_task_root.is_none() {
5751                            sovereign_task_root = Some(path.clone());
5752                            self.pending_teleport_handoff = Some(SovereignTeleportHandoff {
5753                                root: path.clone(),
5754                                plan: build_sovereign_scaffold_handoff(
5755                                    &effective_user_input,
5756                                    &sovereign_scaffold_targets,
5757                                ),
5758                            });
5759                            let _ = tx
5760                                .send(InferenceEvent::Thought(format!(
5761                                    "Sovereign scaffold root established at `{}`; rebinding project context there for the rest of this turn.",
5762                                    path
5763                                )))
5764                                .await;
5765                        }
5766                        self.latest_target_dir = Some(path);
5767                    }
5768
5769                    if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
5770                        if let Some(root) = sovereign_task_root.as_ref() {
5771                            if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
5772                                let resolved = crate::tools::file_ops::resolve_candidate(path);
5773                                let root_path = std::path::Path::new(root);
5774                                if let Ok(relative) = resolved.strip_prefix(root_path) {
5775                                    if !relative.as_os_str().is_empty() {
5776                                        sovereign_scaffold_targets
5777                                            .insert(relative.to_string_lossy().replace('\\', "/"));
5778                                    }
5779                                    self.pending_teleport_handoff =
5780                                        Some(SovereignTeleportHandoff {
5781                                            root: root.clone(),
5782                                            plan: build_sovereign_scaffold_handoff(
5783                                                &effective_user_input,
5784                                                &sovereign_scaffold_targets,
5785                                            ),
5786                                        });
5787                                }
5788                            }
5789                        }
5790                    }
5791                    if matches!(
5792                        tool_name.as_str(),
5793                        "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5794                    ) {
5795                        mutation_occurred = true;
5796                        implementation_started = true;
5797                        if !is_error {
5798                            if let Some(target) = action_target_path(&tool_name, &res.args) {
5799                                turn_mutated_paths.insert(target);
5800                            }
5801                        }
5802                        // Heat tracking: bump L1 score for the edited file.
5803                        if !is_error {
5804                            let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
5805                            if !path.is_empty() {
5806                                self.vein.bump_heat(path);
5807                                self.l1_context = self.vein.l1_context();
5808                                // Compact stale read_file results for this path — the file
5809                                // just changed so old content is wrong and wastes context.
5810                                compact_stale_reads(&mut self.history, path);
5811                            }
5812                            // Refresh repo map so PageRank accounts for the new edit.
5813                            self.refresh_repo_map();
5814                        }
5815                    }
5816
5817                    if !is_error
5818                        && matches!(
5819                            tool_name.as_str(),
5820                            "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5821                        )
5822                    {
5823                        // Internal mutation counts now handled early in sequential loop.
5824                    }
5825
5826                    if res.plan_drafted_this_turn {
5827                        plan_drafted_this_turn = true;
5828                    }
5829                    if let Some(plan) = res.parsed_plan_handoff.clone() {
5830                        self.session_memory.current_plan = Some(plan);
5831                    }
5832
5833                    if tool_name == "verify_build" {
5834                        self.record_session_verification(
5835                            !is_error
5836                                && (final_output.contains("BUILD OK")
5837                                    || final_output.contains("BUILD SUCCESS")
5838                                    || final_output.contains("BUILD OKAY")),
5839                            if is_error {
5840                                "Explicit verify_build failed."
5841                            } else {
5842                                "Explicit verify_build passed."
5843                            },
5844                        );
5845                    }
5846
5847                    // Update Repeat Guard
5848                    let call_key = format!(
5849                        "{}:{}",
5850                        tool_name,
5851                        serde_json::to_string(&res.args).unwrap_or_default()
5852                    );
5853                    let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
5854                    *repeat_count += 1;
5855
5856                    // Structured verification and commit tools are legitimately called multiple
5857                    // times in fix-verify loops.
5858                    let repeat_guard_exempt =
5859                        is_repeat_guard_exempt_tool_call(&tool_name, &res.args);
5860                    if *repeat_count >= 2 && !repeat_guard_exempt {
5861                        loop_intervention = Some(format!(
5862                            "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
5863                             Do not call it again. Either answer directly from what you already know, \
5864                             use a different tool or approach (e.g. if reading the same file, use grep or LSP symbols instead), \
5865                             or ask the user for clarification.",
5866                            tool_name, *repeat_count
5867                        ));
5868                        let _ = tx
5869                            .send(InferenceEvent::Thought(format!(
5870                                "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
5871                                tool_name, *repeat_count
5872                            )))
5873                            .await;
5874                    }
5875
5876                    if *repeat_count >= 3 && !repeat_guard_exempt {
5877                        self.emit_runtime_failure(
5878                            &tx,
5879                            RuntimeFailureClass::ToolLoop,
5880                            &format!(
5881                                "STRICT: You are stuck in a reasoning loop calling `{}`. \
5882                                STOP repeating this call. Switch to grounded filesystem tools \
5883                                (like `read_file`, `inspect_lines`, or `edit_file`) instead of \
5884                                attempting this workflow again.",
5885                                tool_name
5886                            ),
5887                        )
5888                        .await;
5889                        return Ok(());
5890                    }
5891
5892                    if is_error {
5893                        consecutive_errors += 1;
5894                    } else {
5895                        consecutive_errors = 0;
5896                    }
5897
5898                    if consecutive_errors >= 3 {
5899                        loop_intervention = Some(
5900                            "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
5901                             STOP all tool calls immediately. Analyze why your previous 3 calls failed \
5902                             (check for hallucinations or invalid arguments) and ask the user for \
5903                             clarification if you cannot proceed.".to_string()
5904                        );
5905                    }
5906
5907                    if consecutive_errors >= 4 {
5908                        self.emit_runtime_failure(
5909                            &tx,
5910                            RuntimeFailureClass::ToolLoop,
5911                            "Hard termination: too many consecutive tool errors.",
5912                        )
5913                        .await;
5914                        return Ok(());
5915                    }
5916
5917                    if !should_suppress_recoverable_tool_result(
5918                        res.blocked_by_policy,
5919                        recoverable_policy_intervention.is_some(),
5920                    ) {
5921                        let _ = tx
5922                            .send(InferenceEvent::ToolCallResult {
5923                                id: call_id.clone(),
5924                                name: tool_name.clone(),
5925                                result: final_output.clone(),
5926                                is_error,
5927                            })
5928                            .await;
5929                    }
5930
5931                    let repeat_guard_exempt = matches!(
5932                        tool_name.as_str(),
5933                        "verify_build" | "git_commit" | "git_push"
5934                    );
5935                    if !repeat_guard_exempt {
5936                        completed_tool_cache.insert(
5937                            canonical_tool_call_key(&tool_name, &res.args),
5938                            CachedToolResult {
5939                                tool_name: tool_name.clone(),
5940                            },
5941                        );
5942                    }
5943
5944                    // Cap output before history
5945                    let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
5946                        self.engine.current_context_length(),
5947                    );
5948                    let capped = if implement_current_plan {
5949                        cap_output(&final_output, 1200)
5950                    } else if compact_ctx
5951                        && (tool_name == "read_file" || tool_name == "inspect_lines")
5952                    {
5953                        // Compact context: cap file reads tightly and add a navigation hint on truncation.
5954                        let limit = 3000usize;
5955                        if final_output.len() > limit {
5956                            let total_lines = final_output.lines().count();
5957                            let mut split_at = limit;
5958                            while !final_output.is_char_boundary(split_at) && split_at > 0 {
5959                                split_at -= 1;
5960                            }
5961                            let scratch = write_output_to_scratch(&final_output, &tool_name)
5962                                .map(|p| format!(" Full file also saved to '{p}'."))
5963                                .unwrap_or_default();
5964                            format!(
5965                                "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
5966                                &final_output[..split_at],
5967                                total_lines,
5968                                total_lines.saturating_sub(150),
5969                                scratch,
5970                            )
5971                        } else {
5972                            final_output.clone()
5973                        }
5974                    } else {
5975                        cap_output_for_tool(&final_output, 8000, &tool_name)
5976                    };
5977                    self.history.push(ChatMessage::tool_result_for_model(
5978                        &call_id,
5979                        &tool_name,
5980                        &capped,
5981                        &self.engine.current_model(),
5982                    ));
5983                    budget_tool_costs.push(crate::agent::economics::ToolCost {
5984                        name: tool_name.clone(),
5985                        tokens: capped.len() / 4,
5986                    });
5987
5988                    if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
5989                    {
5990                        overview_runtime_trace =
5991                            Some(summarize_runtime_trace_output(&final_output));
5992                    }
5993
5994                    if !architecture_overview_mode
5995                        && !is_error
5996                        && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
5997                            || (toolchain_mode && tool_name == "describe_toolchain"))
5998                    {
5999                        authoritative_tool_output = Some(final_output.clone());
6000                    }
6001
6002                    if !is_error && tool_name == "read_file" {
6003                        if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
6004                            let normalized = normalize_workspace_path(path);
6005                            let read_offset =
6006                                res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
6007                            successful_read_targets.insert(normalized.clone());
6008                            successful_read_regions.insert((normalized.clone(), read_offset));
6009                        }
6010                    }
6011
6012                    if !is_error && tool_name == "grep_files" {
6013                        if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
6014                            let normalized = normalize_workspace_path(path);
6015                            if final_output.starts_with("No matches for ") {
6016                                no_match_grep_targets.insert(normalized);
6017                            } else if grep_output_is_high_fanout(&final_output) {
6018                                broad_grep_targets.insert(normalized);
6019                            } else {
6020                                successful_grep_targets.insert(normalized);
6021                            }
6022                        }
6023                    }
6024
6025                    if is_error
6026                        && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
6027                        && (final_output.contains("search string not found")
6028                            || final_output.contains("search string is too short")
6029                            || final_output.contains("search string matched"))
6030                    {
6031                        if let Some(target) = action_target_path(&tool_name, &res.args) {
6032                            let guidance = if final_output.contains("matched") {
6033                                // Multiple matches — need a more specific anchor. Show the
6034                                // file so the model can pick a unique surrounding context.
6035                                let snippet = read_file_preview_for_retry(&target, 120);
6036                                format!(
6037                                    "EDIT FAILED — search string matched multiple locations in `{target}`. \
6038                                     You need a longer, more unique search string that includes surrounding context.\n\
6039                                     Current file content (first 120 lines):\n```\n{snippet}\n```\n\
6040                                     Retry `{tool_name}` with a search string that is unique in the file."
6041                                )
6042                            } else {
6043                                // Text not found — show the full file so the model can copy
6044                                // the exact current text and retry with correct whitespace.
6045                                let snippet = read_file_preview_for_retry(&target, 200);
6046                                // Also register the file as observed so action_grounding
6047                                // won't block the retry edit.
6048                                let normalized = normalize_workspace_path(&target);
6049                                {
6050                                    let mut ag = self.action_grounding.lock().await;
6051                                    let turn = ag.turn_index;
6052                                    ag.observed_paths.insert(normalized.clone(), turn);
6053                                    ag.inspected_paths.insert(normalized, turn);
6054                                }
6055                                format!(
6056                                    "EDIT FAILED — search string did not match any text in `{target}`.\n\
6057                                     The model must have generated text that differs from what is actually in the file \
6058                                     (wrong whitespace, indentation, or stale content).\n\
6059                                     Current file content (up to 200 lines shown):\n```\n{snippet}\n```\n\
6060                                     Find the exact line(s) to change above, copy the text character-for-character \
6061                                     (preserving indentation), and immediately retry `{tool_name}` \
6062                                     with that exact text as the search string. Do NOT call read_file again — \
6063                                     the content is already shown above."
6064                                )
6065                            };
6066                            loop_intervention = Some(guidance);
6067                            *repeat_count = 0;
6068                        }
6069                    }
6070
6071                    // When guard.rs blocks a shell call with the run_code redirect hint,
6072                    // force the model to recover with run_code instead of giving up.
6073                    if is_error
6074                        && tool_name == "shell"
6075                        && final_output.contains("Use the run_code tool instead")
6076                        && loop_intervention.is_none()
6077                    {
6078                        loop_intervention = Some(
6079                            "STOP. Shell was blocked because this is a computation task. \
6080                             You MUST use `run_code` now — write the code and run it. \
6081                             Do NOT output an error message or give up. \
6082                             Call `run_code` with the appropriate language and code to compute the answer. \
6083                             If writing Python, pass `language: \"python\"`. \
6084                             If writing JavaScript, omit language or pass `language: \"javascript\"`."
6085                                .to_string(),
6086                        );
6087                    }
6088
6089                    // When run_code fails with a Deno parse error, the model likely sent Python
6090                    // code without specifying language: "python". Force a corrective retry.
6091                    if is_error
6092                        && tool_name == "run_code"
6093                        && (final_output.contains("source code could not be parsed")
6094                            || final_output.contains("Expected ';'")
6095                            || final_output.contains("Expected '}'")
6096                            || final_output.contains("is not defined")
6097                                && final_output.contains("deno"))
6098                        && loop_intervention.is_none()
6099                    {
6100                        loop_intervention = Some(
6101                            "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
6102                             code but forgot to pass `language: \"python\"`. \
6103                             Retry run_code with `language: \"python\"` and the same code. \
6104                             Do NOT fall back to shell. Do NOT give up."
6105                                .to_string(),
6106                        );
6107                    }
6108
6109                    if res.blocked_by_policy
6110                        && is_mcp_workspace_read_tool(&tool_name)
6111                        && recoverable_policy_intervention.is_none()
6112                    {
6113                        recoverable_policy_intervention = Some(
6114                            "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
6115                        );
6116                        recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
6117                        recoverable_policy_checkpoint = Some((
6118                            OperatorCheckpointState::BlockedPolicy,
6119                            "MCP workspace read blocked; rerouting to built-in file tools."
6120                                .to_string(),
6121                        ));
6122                    } else if res.blocked_by_policy
6123                        && implement_current_plan
6124                        && is_current_plan_irrelevant_tool(&tool_name)
6125                        && recoverable_policy_intervention.is_none()
6126                    {
6127                        recoverable_policy_intervention = Some(format!(
6128                            "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
6129                            tool_name
6130                        ));
6131                        recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
6132                        recoverable_policy_checkpoint = Some((
6133                            OperatorCheckpointState::BlockedPolicy,
6134                            format!(
6135                                "Current-plan execution blocked unrelated tool `{}`.",
6136                                tool_name
6137                            ),
6138                        ));
6139                    } else if res.blocked_by_policy
6140                        && implement_current_plan
6141                        && final_output
6142                            .contains("current-plan execution is locked to the saved target files")
6143                        && recoverable_policy_intervention.is_none()
6144                    {
6145                        let target_files = self
6146                            .session_memory
6147                            .current_plan
6148                            .as_ref()
6149                            .map(|plan| plan.target_files.clone())
6150                            .unwrap_or_default();
6151                        recoverable_policy_intervention =
6152                            Some(build_current_plan_scope_recovery_prompt(&target_files));
6153                        recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
6154                        recoverable_policy_checkpoint = Some((
6155                            OperatorCheckpointState::BlockedPolicy,
6156                            format!(
6157                                "Current-plan execution blocked off-target path access via `{}`.",
6158                                tool_name
6159                            ),
6160                        ));
6161                    } else if res.blocked_by_policy
6162                        && implement_current_plan
6163                        && final_output.contains("requires recent file evidence")
6164                        && recoverable_policy_intervention.is_none()
6165                    {
6166                        let target = action_target_path(&tool_name, &res.args)
6167                            .unwrap_or_else(|| "the target file".to_string());
6168                        recoverable_policy_intervention = Some(format!(
6169                            "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
6170                        ));
6171                        recoverable_policy_recipe =
6172                            Some(RecoveryScenario::RecentFileEvidenceMissing);
6173                        recoverable_policy_checkpoint = Some((
6174                            OperatorCheckpointState::BlockedRecentFileEvidence,
6175                            format!("Edit blocked on `{target}`; recent file evidence missing."),
6176                        ));
6177                    } else if res.blocked_by_policy
6178                        && implement_current_plan
6179                        && final_output.contains("requires an exact local line window first")
6180                        && recoverable_policy_intervention.is_none()
6181                    {
6182                        let target = action_target_path(&tool_name, &res.args)
6183                            .unwrap_or_else(|| "the target file".to_string());
6184                        recoverable_policy_intervention = Some(format!(
6185                            "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
6186                        ));
6187                        recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
6188                        recoverable_policy_checkpoint = Some((
6189                            OperatorCheckpointState::BlockedExactLineWindow,
6190                            format!("Edit blocked on `{target}`; exact line window required."),
6191                        ));
6192                    } else if res.blocked_by_policy
6193                        && (final_output.contains("Prefer `")
6194                            || final_output.contains("Prefer tool"))
6195                        && recoverable_policy_intervention.is_none()
6196                    {
6197                        recoverable_policy_intervention = Some(final_output.clone());
6198                        recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
6199                        recoverable_policy_checkpoint = Some((
6200                            OperatorCheckpointState::BlockedPolicy,
6201                            "Action blocked by policy; self-correction triggered using tool recommendation."
6202                                .to_string(),
6203                        ));
6204                    } else if res.blocked_by_policy && blocked_policy_output.is_none() {
6205                        blocked_policy_output = Some(final_output.clone());
6206                    }
6207
6208                    if *repeat_count >= 5 {
6209                        let _ = tx.send(InferenceEvent::Done).await;
6210                        return Ok(());
6211                    }
6212
6213                    if implement_current_plan
6214                        && !implementation_started
6215                        && !is_error
6216                        && is_non_mutating_plan_step_tool(&tool_name)
6217                    {
6218                        non_mutating_plan_steps += 1;
6219                    }
6220                }
6221
6222                if sovereign_bootstrap_complete
6223                    && intent.sovereign_mode
6224                    && is_scaffold_request(&effective_user_input)
6225                {
6226                    let response = if let Some(root) = sovereign_task_root.as_deref() {
6227                        format!(
6228                            "Project root created at `{root}`. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
6229                        )
6230                    } else {
6231                        "Project root created. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
6232                            .to_string()
6233                    };
6234                    self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
6235                        .await;
6236                    return Ok(());
6237                }
6238
6239                if let Some(intervention) = recoverable_policy_intervention {
6240                    if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
6241                        self.emit_operator_checkpoint(&tx, state, summary).await;
6242                    }
6243                    if let Some(scenario) = recoverable_policy_recipe.take() {
6244                        let recipe = plan_recovery(scenario, &self.recovery_context);
6245                        self.emit_recovery_recipe_summary(
6246                            &tx,
6247                            recipe.recipe.scenario.label(),
6248                            compact_recovery_plan_summary(&recipe),
6249                        )
6250                        .await;
6251                    }
6252                    loop_intervention = Some(intervention);
6253                    let _ = tx
6254                        .send(InferenceEvent::Thought(
6255                            "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
6256                                .into(),
6257                        ))
6258                        .await;
6259                    continue;
6260                }
6261
6262                if architecture_overview_mode {
6263                    match overview_runtime_trace.as_deref() {
6264                        Some(runtime_trace) => {
6265                            let response = build_architecture_overview_answer(runtime_trace);
6266                            self.history.push(ChatMessage::assistant_text(&response));
6267                            self.transcript.log_agent(&response);
6268
6269                            for chunk in chunk_text(&response, 8) {
6270                                if !chunk.is_empty() {
6271                                    let _ = tx.send(InferenceEvent::Token(chunk)).await;
6272                                }
6273                            }
6274
6275                            let _ = tx.send(InferenceEvent::Done).await;
6276                            break;
6277                        }
6278                        None => {
6279                            loop_intervention = Some(
6280                                "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."
6281                                    .to_string(),
6282                            );
6283                            continue;
6284                        }
6285                    }
6286                }
6287
6288                if implement_current_plan
6289                    && !implementation_started
6290                    && non_mutating_plan_steps >= non_mutating_plan_hard_cap
6291                {
6292                    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();
6293                    self.history.push(ChatMessage::assistant_text(&msg));
6294                    self.transcript.log_agent(&msg);
6295
6296                    for chunk in chunk_text(&msg, 8) {
6297                        if !chunk.is_empty() {
6298                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6299                        }
6300                    }
6301
6302                    let _ = tx.send(InferenceEvent::Done).await;
6303                    break;
6304                }
6305
6306                if let Some(blocked_output) = blocked_policy_output {
6307                    self.emit_operator_checkpoint(
6308                        &tx,
6309                        OperatorCheckpointState::BlockedPolicy,
6310                        "A blocked tool path was surfaced directly to the operator.",
6311                    )
6312                    .await;
6313                    self.history
6314                        .push(ChatMessage::assistant_text(&blocked_output));
6315                    self.transcript.log_agent(&blocked_output);
6316
6317                    for chunk in chunk_text(&blocked_output, 8) {
6318                        if !chunk.is_empty() {
6319                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6320                        }
6321                    }
6322
6323                    let _ = tx.send(InferenceEvent::Done).await;
6324                    break;
6325                }
6326
6327                if let Some(tool_output) = authoritative_tool_output {
6328                    self.history.push(ChatMessage::assistant_text(&tool_output));
6329                    self.transcript.log_agent(&tool_output);
6330
6331                    for chunk in chunk_text(&tool_output, 8) {
6332                        if !chunk.is_empty() {
6333                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6334                        }
6335                    }
6336
6337                    let _ = tx.send(InferenceEvent::Done).await;
6338                    break;
6339                }
6340
6341                if implement_current_plan && !implementation_started {
6342                    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.";
6343                    if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
6344                        loop_intervention = Some(format!(
6345                            "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
6346                            base
6347                        ));
6348                    } else {
6349                        loop_intervention = Some(base.to_string());
6350                    }
6351                } else if self.workflow_mode == WorkflowMode::Architect {
6352                    loop_intervention = Some(
6353                        format!(
6354                            "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.",
6355                            architect_handoff_contract()
6356                        ),
6357                    );
6358                }
6359
6360                // 4. Auto-Verification Loop (The Perfect Bake)
6361                if mutation_occurred && !yolo && !intent.sovereign_mode {
6362                    let _ = tx
6363                        .send(InferenceEvent::Thought(
6364                            "Self-Verification: Running contract-aware workspace verification..."
6365                                .into(),
6366                        ))
6367                        .await;
6368                    let verify_outcome = self.auto_verify_workspace(&turn_mutated_paths).await;
6369                    let verify_res = verify_outcome.summary;
6370                    let verify_ok = verify_outcome.ok;
6371                    self.record_verify_build_result(verify_ok, &verify_res)
6372                        .await;
6373                    self.record_session_verification(
6374                        verify_ok,
6375                        if verify_ok {
6376                            "Automatic workspace verification passed."
6377                        } else {
6378                            "Automatic workspace verification failed."
6379                        },
6380                    );
6381                    self.history.push(ChatMessage::system(&format!(
6382                        "\n# SYSTEM VERIFICATION\n{verify_res}"
6383                    )));
6384                    let _ = tx
6385                        .send(InferenceEvent::Thought(
6386                            "Verification turn injected into history.".into(),
6387                        ))
6388                        .await;
6389                }
6390
6391                // Continue loop – the model will respond to the results.
6392                continue;
6393            } else if let Some(response_text) = text {
6394                if finish_reason.as_deref() == Some("length") && near_context_ceiling {
6395                    if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
6396                        let cleaned = build_session_reset_semantics_answer();
6397                        self.history.push(ChatMessage::assistant_text(&cleaned));
6398                        self.transcript.log_agent(&cleaned);
6399                        for chunk in chunk_text(&cleaned, 8) {
6400                            if !chunk.is_empty() {
6401                                let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6402                            }
6403                        }
6404                        let _ = tx.send(InferenceEvent::Done).await;
6405                        break;
6406                    }
6407
6408                    let warning = format_runtime_failure(
6409                        RuntimeFailureClass::ContextWindow,
6410                        "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.",
6411                    );
6412                    self.history.push(ChatMessage::assistant_text(&warning));
6413                    self.transcript.log_agent(&warning);
6414                    let _ = tx
6415                        .send(InferenceEvent::Thought(
6416                            "Length recovery: model hit the context ceiling before completing the answer."
6417                                .into(),
6418                        ))
6419                        .await;
6420                    for chunk in chunk_text(&warning, 8) {
6421                        if !chunk.is_empty() {
6422                            let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6423                        }
6424                    }
6425                    let _ = tx.send(InferenceEvent::Done).await;
6426                    break;
6427                }
6428
6429                if response_text.contains("<|tool_call")
6430                    || response_text.contains("[END_TOOL_REQUEST]")
6431                    || response_text.contains("<|tool_response")
6432                    || response_text.contains("<tool_response|>")
6433                {
6434                    loop_intervention = Some(
6435                        "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(),
6436                    );
6437                    continue;
6438                }
6439
6440                // 1. Process and route the reasoning block to SPECULAR.
6441                if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
6442                {
6443                    let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
6444                    // Persist for history audit (stripped from next turn by Volatile Reasoning rule).
6445                    // This will be summarized in the next turn's system prompt.
6446                    self.reasoning_history = Some(thought);
6447                }
6448
6449                let execution_ms = execution_start.elapsed().as_millis();
6450                let _ = tx
6451                    .send(InferenceEvent::TurnTiming {
6452                        context_prep_ms,
6453                        inference_ms,
6454                        execution_ms,
6455                    })
6456                    .await;
6457
6458                // 2. Process and stream the final answer to the chat interface.
6459                let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
6460
6461                if implement_current_plan && !implementation_started {
6462                    loop_intervention = Some(
6463                        "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(),
6464                    );
6465                    continue;
6466                }
6467
6468                // [Hardened Interface] Strictly respect the stripper.
6469                // If it's empty after stripping think blocks, the model thought through its
6470                // answer but forgot to emit it (common with Qwen3 models in architect/ask mode).
6471                // Nudge it rather than silently dropping the turn — but cap at 2 retries so a
6472                // model that keeps returning whitespace/empty doesn't spin all 25 iterations.
6473                if cleaned.is_empty() {
6474                    empty_cleaned_nudges += 1;
6475                    if empty_cleaned_nudges == 1 {
6476                        loop_intervention = Some(
6477                            "Your visible response was empty. The tool already returned data. \
6478                             Write your answer now in plain text — no <think> tags, no tool calls. \
6479                             State the key facts in 2-5 sentences and stop."
6480                                .to_string(),
6481                        );
6482                        continue;
6483                    } else if empty_cleaned_nudges == 2 {
6484                        loop_intervention = Some(
6485                            "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
6486                             Write the answer in plain text right now. \
6487                             Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
6488                                .to_string(),
6489                        );
6490                        continue;
6491                    }
6492                    if let Some(summary) = maybe_deterministic_sovereign_closeout(
6493                        self.session_memory.current_plan.as_ref(),
6494                        mutation_occurred,
6495                    ) {
6496                        self.history.push(ChatMessage::assistant_text(&summary));
6497                        self.transcript.log_agent(&summary);
6498                        for chunk in chunk_text(&summary, 8) {
6499                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6500                        }
6501                        let _ = tx.send(InferenceEvent::Done).await;
6502                        return Ok(());
6503                    }
6504
6505                    let last_was_tool = self
6506                        .history
6507                        .last()
6508                        .map(|m| m.role == "tool")
6509                        .unwrap_or(false);
6510                    if last_was_tool {
6511                        let fallback = "[Proof successful. See tool output above for results.]";
6512                        self.history.push(ChatMessage::assistant_text(fallback));
6513                        self.transcript.log_agent(fallback);
6514                        for chunk in chunk_text(fallback, 8) {
6515                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6516                        }
6517                        let _ = tx.send(InferenceEvent::Done).await;
6518                        return Ok(());
6519                    }
6520
6521                    self.emit_runtime_failure(
6522                        &tx,
6523                        RuntimeFailureClass::EmptyModelResponse,
6524                        "Model returned empty content after 2 nudge attempts.",
6525                    )
6526                    .await;
6527                    break;
6528                }
6529
6530                let architect_handoff = self.persist_architect_handoff(&cleaned);
6531                self.history.push(ChatMessage::assistant_text(&cleaned));
6532                self.transcript.log_agent(&cleaned);
6533                visible_closeout_emitted = true;
6534
6535                // Send in smooth chunks for that professional UI feel.
6536                for chunk in chunk_text(&cleaned, 8) {
6537                    if !chunk.is_empty() {
6538                        let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6539                    }
6540                }
6541
6542                if let Some(plan) = architect_handoff.as_ref() {
6543                    let note = architect_handoff_operator_note(plan);
6544                    self.history.push(ChatMessage::system(&note));
6545                    self.transcript.log_system(&note);
6546                    let _ = tx
6547                        .send(InferenceEvent::MutedToken(format!("\n{}", note)))
6548                        .await;
6549                }
6550
6551                self.emit_done_events(&tx).await;
6552                break;
6553            } else {
6554                let detail = "Model returned an empty response.";
6555                let class = classify_runtime_failure(detail);
6556                if should_retry_runtime_failure(class) {
6557                    if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
6558                        if let RecoveryDecision::Attempt(plan) =
6559                            attempt_recovery(scenario, &mut self.recovery_context)
6560                        {
6561                            self.transcript.log_system(
6562                                "Automatic provider recovery triggered: model returned an empty response.",
6563                            );
6564                            self.emit_recovery_recipe_summary(
6565                                &tx,
6566                                plan.recipe.scenario.label(),
6567                                compact_recovery_plan_summary(&plan),
6568                            )
6569                            .await;
6570                            let _ = tx
6571                                .send(InferenceEvent::ProviderStatus {
6572                                    state: ProviderRuntimeState::Recovering,
6573                                    summary: compact_runtime_recovery_summary(class).into(),
6574                                })
6575                                .await;
6576                            self.emit_operator_checkpoint(
6577                                &tx,
6578                                OperatorCheckpointState::RecoveringProvider,
6579                                compact_runtime_recovery_summary(class),
6580                            )
6581                            .await;
6582                            continue;
6583                        }
6584                    }
6585                }
6586
6587                if explicit_search_request
6588                    && matches!(
6589                        class,
6590                        RuntimeFailureClass::ProviderDegraded
6591                            | RuntimeFailureClass::EmptyModelResponse
6592                    )
6593                {
6594                    if let Some(results) = grounded_research_results.as_deref() {
6595                        let response = build_research_provider_fallback(results);
6596                        self.history.push(ChatMessage::assistant_text(&response));
6597                        self.transcript.log_agent(&response);
6598                        for chunk in chunk_text(&response, 8) {
6599                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6600                        }
6601                        let _ = tx.send(InferenceEvent::Done).await;
6602                        return Ok(());
6603                    }
6604                }
6605
6606                if implement_current_plan
6607                    && mutation_occurred
6608                    && matches!(class, RuntimeFailureClass::EmptyModelResponse)
6609                {
6610                    if let Some(summary) = maybe_deterministic_sovereign_closeout(
6611                        self.session_memory.current_plan.as_ref(),
6612                        mutation_occurred,
6613                    ) {
6614                        self.history.push(ChatMessage::assistant_text(&summary));
6615                        self.transcript.log_agent(&summary);
6616                        for chunk in chunk_text(&summary, 8) {
6617                            let _ = tx.send(InferenceEvent::Token(chunk)).await;
6618                        }
6619                        let _ = tx.send(InferenceEvent::Done).await;
6620                        return Ok(());
6621                    }
6622                }
6623
6624                self.emit_runtime_failure(&tx, class, detail).await;
6625                break;
6626            }
6627        }
6628
6629        let task_progress_after = if implement_current_plan {
6630            read_task_checklist_progress()
6631        } else {
6632            None
6633        };
6634
6635        if implement_current_plan
6636            && !visible_closeout_emitted
6637            && should_continue_plan_execution(
6638                current_plan_pass,
6639                task_progress_before,
6640                task_progress_after,
6641                &turn_mutated_paths,
6642            )
6643        {
6644            if let Some(progress) = task_progress_after {
6645                let _ = tx
6646                    .send(InferenceEvent::Thought(format!(
6647                        "Checklist still has {} unchecked item(s). Continuing autonomous implementation pass {}.",
6648                        progress.remaining,
6649                        current_plan_pass + 1
6650                    )))
6651                    .await;
6652                let synthetic_turn = UserTurn {
6653                    text: build_continue_plan_execution_prompt(progress),
6654                    attached_document: None,
6655                    attached_image: None,
6656                };
6657                return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6658            }
6659        }
6660
6661        if implement_current_plan
6662            && !visible_closeout_emitted
6663            && turn_mutated_paths.is_empty()
6664            && current_plan_pass == 1
6665        {
6666            if let Some(progress) = task_progress_after.filter(|progress| progress.has_open_items())
6667            {
6668                let target_files = self
6669                    .session_memory
6670                    .current_plan
6671                    .as_ref()
6672                    .map(|plan| plan.target_files.clone())
6673                    .unwrap_or_default();
6674                let _ = tx
6675                    .send(InferenceEvent::Thought(
6676                        "No target files were mutated during the first current-plan pass. Forcing one grounded implementation retry before allowing summary mode."
6677                            .to_string(),
6678                    ))
6679                    .await;
6680                let synthetic_turn = UserTurn {
6681                    text: build_force_plan_mutation_prompt(progress, &target_files),
6682                    attached_document: None,
6683                    attached_image: None,
6684                };
6685                return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6686            }
6687        }
6688
6689        if implement_current_plan
6690            && !visible_closeout_emitted
6691            && !turn_mutated_paths.is_empty()
6692            && current_plan_pass <= 2
6693        {
6694            if let (Some(before), Some(after)) = (task_progress_before, task_progress_after) {
6695                if after.has_open_items()
6696                    && after.remaining == before.remaining
6697                    && after.completed == before.completed
6698                {
6699                    let target_files = self
6700                        .session_memory
6701                        .current_plan
6702                        .as_ref()
6703                        .map(|plan| plan.target_files.clone())
6704                        .unwrap_or_default();
6705                    let _ = tx
6706                        .send(InferenceEvent::Thought(
6707                            "Implementation mutated target files, but the task ledger did not advance. Forcing one closeout pass to update `.hematite/TASK.md` before summary mode."
6708                                .to_string(),
6709                        ))
6710                        .await;
6711                    let synthetic_turn = UserTurn {
6712                        text: build_task_ledger_closeout_prompt(after, &target_files),
6713                        attached_document: None,
6714                        attached_image: None,
6715                    };
6716                    return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6717                }
6718            }
6719        }
6720
6721        if implement_current_plan && !visible_closeout_emitted {
6722            // FORCE a summary turn if we had no natural closeout (e.g. hit a mandate or finished all tool budget).
6723            let _ = tx.send(InferenceEvent::Thought("Implementation passthrough complete. Requesting final engineering summary (NLG-only mode)...".to_string())).await;
6724
6725            let outstanding_note = task_progress_after
6726                .filter(|progress| progress.has_open_items())
6727                .map(|progress| {
6728                    format!(
6729                        " `.hematite/TASK.md` still has {} unchecked item(s); explain the concrete blocker or remaining non-optional work.",
6730                        progress.remaining
6731                    )
6732                })
6733                .unwrap_or_default();
6734            let synthetic_turn = UserTurn {
6735                text: format!(
6736                    "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.{}",
6737                    outstanding_note
6738                ),
6739                attached_document: None,
6740                attached_image: None,
6741            };
6742            // Note: We use recursion to force one last NLG pass.
6743            // We set yolo=true to suppress tools.
6744            return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), true)).await;
6745        }
6746
6747        if plan_drafted_this_turn
6748            && matches!(
6749                self.workflow_mode,
6750                WorkflowMode::Auto | WorkflowMode::Architect
6751            )
6752        {
6753            let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
6754            let _ = tx
6755                .send(InferenceEvent::ApprovalRequired {
6756                    id: "plan_approval".to_string(),
6757                    name: "plan_authorization".to_string(),
6758                    display: "A comprehensive scaffolding blueprint has been drafted to .hematite/PLAN.md. Autonomously execute implementation now?".to_string(),
6759                    diff: None,
6760                    mutation_label: Some("SYSTEM PLAN AUTHORIZATION".to_string()),
6761                    responder: appr_tx,
6762                })
6763                .await;
6764
6765            if let Ok(true) = appr_rx.await {
6766                // Wipe conversation history to prevent hallucination cycles on 9B models.
6767                // The recursive run_turn call will rebuild the system prompt from scratch
6768                // and inject the PLAN.md blueprint via the implement-plan pathway.
6769                self.history.clear();
6770                self.running_summary = None;
6771                self.set_workflow_mode(WorkflowMode::Code);
6772
6773                let _ = tx.send(InferenceEvent::ChainImplementPlan).await;
6774
6775                let next_input = implement_current_plan_prompt().to_string();
6776                let synthetic_turn = UserTurn {
6777                    text: next_input,
6778                    attached_document: None,
6779                    attached_image: None,
6780                };
6781                return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6782            }
6783        }
6784
6785        self.trim_history(80);
6786        self.refresh_session_memory();
6787        // Record the goal and increment the turn counter before persisting.
6788        self.last_goal = Some(user_input.chars().take(300).collect());
6789        self.turn_count = self.turn_count.saturating_add(1);
6790        self.emit_compaction_pressure(&tx).await;
6791
6792        // ── Context budget ledger ────────────────────────────────────────────
6793        {
6794            let (input_end, output_end) = {
6795                let econ = self
6796                    .engine
6797                    .economics
6798                    .lock()
6799                    .unwrap_or_else(|p| p.into_inner());
6800                (econ.input_tokens, econ.output_tokens)
6801            };
6802            let context_pct = {
6803                let ctx_len = self.engine.current_context_length();
6804                let total = input_end.saturating_sub(budget_input_start)
6805                    + output_end.saturating_sub(budget_output_start);
6806                (total * 100).checked_div(ctx_len).unwrap_or(0).min(100) as u8
6807            };
6808            // Collapse duplicate tool names into summed costs (insertion order preserved).
6809            let mut tool_costs: Vec<crate::agent::economics::ToolCost> =
6810                Vec::with_capacity(budget_tool_costs.len());
6811            for tc in &budget_tool_costs {
6812                if let Some(existing) = tool_costs.iter_mut().find(|e| e.name == tc.name) {
6813                    existing.tokens += tc.tokens;
6814                } else {
6815                    tool_costs.push(crate::agent::economics::ToolCost {
6816                        name: tc.name.clone(),
6817                        tokens: tc.tokens,
6818                    });
6819                }
6820            }
6821            let budget = crate::agent::economics::TurnBudget {
6822                input_tokens: input_end.saturating_sub(budget_input_start),
6823                output_tokens: output_end.saturating_sub(budget_output_start),
6824                history_est: budget_history_est,
6825                tool_costs,
6826                context_pct,
6827            };
6828            let _ = tx.send(InferenceEvent::Thought(budget.render())).await;
6829            self.last_turn_budget = Some(budget);
6830        }
6831
6832        // AUTHORITATIVE TURN SUMMARY: Generate and display unified diffs.
6833        if !implement_current_plan {
6834            let tracker = self.diff_tracker.lock().await;
6835            if let Ok(diff) = tracker.generate_diff() {
6836                if !diff.is_empty() {
6837                    let _ = tx
6838                        .send(InferenceEvent::Thought(format!(
6839                            "AUTHORITATIVE TURN SUMMARY:\n\n```diff\n{}\n```",
6840                            diff
6841                        )))
6842                        .await;
6843
6844                    // Also log to transcript for persistence.
6845                    self.transcript
6846                        .log_system(&format!("Turn Diff Summary:\n{}", diff));
6847                }
6848            }
6849        }
6850
6851        Ok(())
6852    }
6853
6854    async fn emit_runtime_failure(
6855        &mut self,
6856        tx: &mpsc::Sender<InferenceEvent>,
6857        class: RuntimeFailureClass,
6858        detail: &str,
6859    ) {
6860        if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
6861            let decision = preview_recovery_decision(scenario, &self.recovery_context);
6862            self.emit_recovery_recipe_summary(
6863                tx,
6864                scenario.label(),
6865                compact_recovery_decision_summary(&decision),
6866            )
6867            .await;
6868            let needs_refresh = match &decision {
6869                RecoveryDecision::Attempt(plan) => plan
6870                    .recipe
6871                    .steps
6872                    .contains(&RecoveryStep::RefreshRuntimeProfile),
6873                RecoveryDecision::Escalate { recipe, .. } => {
6874                    recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
6875                }
6876            };
6877            if needs_refresh {
6878                if let Some((model_id, context_length, changed)) = self
6879                    .refresh_runtime_profile_and_report(tx, "context_window_failure")
6880                    .await
6881                {
6882                    let note = if changed {
6883                        format!(
6884                            "Runtime refresh after context-window failure: model {} | CTX {}",
6885                            model_id, context_length
6886                        )
6887                    } else {
6888                        format!(
6889                            "Runtime refresh after context-window failure confirms model {} | CTX {}",
6890                            model_id, context_length
6891                        )
6892                    };
6893                    let _ = tx.send(InferenceEvent::Thought(note)).await;
6894                }
6895            }
6896        }
6897        if let Some(state) = provider_state_for_runtime_failure(class) {
6898            let _ = tx
6899                .send(InferenceEvent::ProviderStatus {
6900                    state,
6901                    summary: compact_runtime_failure_summary(class).into(),
6902                })
6903                .await;
6904        }
6905        if let Some(state) = checkpoint_state_for_runtime_failure(class) {
6906            self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
6907                .await;
6908        }
6909        let formatted = format_runtime_failure(class, detail);
6910        self.history.push(ChatMessage::system(&format!(
6911            "# RUNTIME FAILURE\n{}",
6912            formatted
6913        )));
6914        self.transcript.log_system(&formatted);
6915        let _ = tx.send(InferenceEvent::Error(formatted)).await;
6916        let _ = tx.send(InferenceEvent::Done).await;
6917    }
6918
6919    /// Contract-aware self verification. Build is still the base proof, but stack-specific
6920    /// runtime contracts can add stronger checks such as website route and asset validation.
6921    async fn auto_verify_workspace(
6922        &self,
6923        mutated_paths: &std::collections::BTreeSet<String>,
6924    ) -> AutoVerificationOutcome {
6925        let root = crate::tools::file_ops::workspace_root();
6926        let profile = crate::agent::workspace_profile::load_workspace_profile(&root)
6927            .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(&root));
6928
6929        let mut sections = Vec::with_capacity(4);
6930        let mut overall_ok = true;
6931        let contract = profile.runtime_contract.as_ref();
6932        let verification_workflows: Vec<String> = match contract {
6933            Some(contract) if !contract.verification_workflows.is_empty() => {
6934                contract.verification_workflows.clone()
6935            }
6936            _ if profile.build_hint.is_some() || profile.verify_profile.is_some() => {
6937                vec!["build".to_string()]
6938            }
6939            _ => Vec::new(),
6940        };
6941
6942        for workflow in verification_workflows {
6943            if !should_run_contract_verification_workflow(contract, &workflow, mutated_paths) {
6944                continue;
6945            }
6946            let outcome = self.auto_run_verification_workflow(&workflow).await;
6947            overall_ok &= outcome.ok;
6948            sections.push(outcome.summary);
6949        }
6950
6951        if sections.is_empty() {
6952            sections.push(
6953                "[verify]\nVERIFICATION SKIPPED: Workspace profile does not define an automatic verification workflow for this stack."
6954                    .to_string(),
6955            );
6956        }
6957
6958        let header = if overall_ok {
6959            "WORKSPACE VERIFICATION SUCCESS: Automatic validation passed."
6960        } else {
6961            "WORKSPACE VERIFICATION FAILURE: Automatic validation found problems."
6962        };
6963
6964        AutoVerificationOutcome {
6965            ok: overall_ok,
6966            summary: format!("{}\n\n{}", header, sections.join("\n\n")),
6967        }
6968    }
6969
6970    async fn auto_run_verification_workflow(&self, workflow: &str) -> AutoVerificationOutcome {
6971        match workflow {
6972            "build" | "test" | "lint" | "fix" => {
6973                match crate::tools::verify_build::execute(
6974                    &serde_json::json!({ "action": workflow }),
6975                )
6976                .await
6977                {
6978                    Ok(out) => AutoVerificationOutcome {
6979                        ok: true,
6980                        summary: format!(
6981                            "[{}]\n{} SUCCESS: Automatic {} verification passed.\n\n{}",
6982                            workflow,
6983                            workflow.to_ascii_uppercase(),
6984                            workflow,
6985                            cap_output(&out, 2000)
6986                        ),
6987                    },
6988                    Err(e) => AutoVerificationOutcome {
6989                        ok: false,
6990                        summary: format!(
6991                            "[{}]\n{} FAILURE: Automatic {} verification failed.\n\n{}",
6992                            workflow,
6993                            workflow.to_ascii_uppercase(),
6994                            workflow,
6995                            cap_output(&e, 2000)
6996                        ),
6997                    },
6998                }
6999            }
7000            other => {
7001                // DISPATCH Generic workflows (e.g. website_validate, server_probe, etc.)
7002                let args = serde_json::json!({ "workflow": other });
7003                match crate::tools::workspace_workflow::run_workspace_workflow(&args).await {
7004                    Ok(out) => {
7005                        // Specialized workflows rely on "Result: PASS" or "Result: FAIL" markers.
7006                        // Standard shell fallbacks return OK if exit code was 0.
7007                        let ok = !out.contains("Result: FAIL") && !out.contains("Error:");
7008                        AutoVerificationOutcome {
7009                            ok,
7010                            summary: format!("[{}]\n{}", other, out.trim()),
7011                        }
7012                    }
7013                    Err(e) => {
7014                        // If a specialized workflow needs "Auto-Booting" (e.g. website),
7015                        // we can handle a retry here or delegate the intelligence to the tool itself.
7016                        // For website_validate, we attempt a boot if it looks like a connection failure.
7017                        let needs_boot = e.contains("No tracked website server labeled")
7018                            || e.contains("HTTP probe failed")
7019                            || e.contains("Connection refused")
7020                            || e.contains("error trying to connect");
7021
7022                        if other == "website_validate" && needs_boot {
7023                            let start_args = serde_json::json!({ "workflow": "website_start" });
7024                            if crate::tools::workspace_workflow::run_workspace_workflow(&start_args)
7025                                .await
7026                                .is_ok()
7027                            {
7028                                if let Ok(retry_out) =
7029                                    crate::tools::workspace_workflow::run_workspace_workflow(&args)
7030                                        .await
7031                                {
7032                                    let ok = !retry_out.contains("Result: FAIL")
7033                                        && !retry_out.contains("Error:");
7034                                    return AutoVerificationOutcome {
7035                                        ok,
7036                                        summary: format!(
7037                                            "[{}]\n(Auto-booted) {}",
7038                                            other,
7039                                            retry_out.trim()
7040                                        ),
7041                                    };
7042                                }
7043                            }
7044                        }
7045
7046                        AutoVerificationOutcome {
7047                            ok: false,
7048                            summary: format!("[{}]\nVERIFICATION FAILURE: {}", other, e),
7049                        }
7050                    }
7051                }
7052            }
7053        }
7054    }
7055
7056    /// Triggers an LLM call to summarize old messages if history exceeds the VRAM character limit.
7057    /// Triggers the Deterministic Smart Compaction algorithm to shrink history while preserving context.
7058    /// Triggers the Recursive Context Compactor.
7059    async fn compact_history_if_needed(
7060        &mut self,
7061        tx: &mpsc::Sender<InferenceEvent>,
7062        anchor_index: Option<usize>,
7063    ) -> Result<bool, String> {
7064        let vram_ratio = self.gpu_state.ratio();
7065        let context_length = self.engine.current_context_length();
7066        let config = CompactionConfig::adaptive(context_length, vram_ratio);
7067
7068        if !compaction::should_compact(&self.history, context_length, vram_ratio) {
7069            return Ok(false);
7070        }
7071
7072        let _ = tx
7073            .send(InferenceEvent::Thought(format!(
7074                "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
7075                context_length / 1000,
7076                vram_ratio * 100.0,
7077                config.max_estimated_tokens / 1000,
7078            )))
7079            .await;
7080
7081        let result = compaction::compact_history(
7082            &self.history,
7083            self.running_summary.as_deref(),
7084            config,
7085            anchor_index,
7086        );
7087
7088        let removed_message_count = self.history.len().saturating_sub(result.messages.len());
7089        self.history = result.messages;
7090        self.running_summary = result.summary;
7091
7092        // Layer 6: Memory Synthesis (Task Context Persistence)
7093        let last_checkpoint = self.session_memory.last_checkpoint.take();
7094        let last_blocker = self.session_memory.last_blocker.take();
7095        let last_recovery = self.session_memory.last_recovery.take();
7096        let last_verification = self.session_memory.last_verification.take();
7097        let last_compaction = self.session_memory.last_compaction.take();
7098        self.session_memory = compaction::extract_memory(&self.history);
7099        self.session_memory.last_checkpoint = last_checkpoint;
7100        self.session_memory.last_blocker = last_blocker;
7101        self.session_memory.last_recovery = last_recovery;
7102        self.session_memory.last_verification = last_verification;
7103        self.session_memory.last_compaction = last_compaction;
7104        self.session_memory.record_compaction(
7105            removed_message_count,
7106            format!(
7107                "Compacted history around active task '{}' and preserved {} working-set file(s).",
7108                self.session_memory.current_task,
7109                self.session_memory.working_set.len()
7110            ),
7111        );
7112        self.emit_compaction_pressure(tx).await;
7113
7114        // Jinja alignment: preserved slice may start with assistant/tool messages.
7115        // Strip any leading non-user messages so the first non-system message is always user.
7116        let first_non_sys = self
7117            .history
7118            .iter()
7119            .position(|m| m.role != "system")
7120            .unwrap_or(self.history.len());
7121        if first_non_sys < self.history.len() {
7122            if let Some(user_offset) = self.history[first_non_sys..]
7123                .iter()
7124                .position(|m| m.role == "user")
7125            {
7126                if user_offset > 0 {
7127                    self.history
7128                        .drain(first_non_sys..first_non_sys + user_offset);
7129                }
7130            }
7131        }
7132
7133        let _ = tx
7134            .send(InferenceEvent::Thought(format!(
7135                "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
7136                self.session_memory.current_task,
7137                self.session_memory.working_set.len()
7138            )))
7139            .await;
7140        let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
7141        self.emit_recovery_recipe_summary(
7142            tx,
7143            recipe.recipe.scenario.label(),
7144            compact_recovery_plan_summary(&recipe),
7145        )
7146        .await;
7147        self.emit_operator_checkpoint(
7148            tx,
7149            OperatorCheckpointState::HistoryCompacted,
7150            format!(
7151                "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
7152                self.session_memory.current_task,
7153                self.session_memory.working_set.len()
7154            ),
7155        )
7156        .await;
7157
7158        Ok(true)
7159    }
7160
7161    /// Query The Vein for context relevant to the user's message.
7162    /// Runs hybrid BM25 + semantic search (semantic requires embedding model in LM Studio).
7163    /// Returns a formatted system message string, or None if nothing useful found.
7164    fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
7165        // Skip trivial / very short inputs.
7166        if query.split_whitespace().count() < 3 {
7167            return None;
7168        }
7169
7170        let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
7171        if results.is_empty() {
7172            return None;
7173        }
7174
7175        let semantic_active = self.vein.has_any_embeddings();
7176        let header = if semantic_active {
7177            "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
7178             Use this to answer without needing extra read_file calls where possible.\n\n"
7179        } else {
7180            "# Relevant context from The Vein (BM25 keyword retrieval)\n\
7181             Use this to answer without needing extra read_file calls where possible.\n\n"
7182        };
7183
7184        let mut ctx = String::from(header);
7185        let mut paths: Vec<String> = Vec::with_capacity(results.len());
7186
7187        let mut total = 0usize;
7188        const MAX_CTX_CHARS: usize = 1_500;
7189
7190        for r in results {
7191            if total >= MAX_CTX_CHARS {
7192                break;
7193            }
7194            let snippet = if r.content.len() > 500 {
7195                format!("{}...", safe_head(&r.content, 500))
7196            } else {
7197                r.content.clone()
7198            };
7199            let _ = write!(ctx, "--- {} ---\n{}\n\n", r.path, snippet);
7200            total += snippet.len() + r.path.len() + 10;
7201            if !paths.contains(&r.path) {
7202                paths.push(r.path);
7203            }
7204        }
7205
7206        Some((ctx, paths))
7207    }
7208
7209    /// Returns the conversation history (WITHOUT the system prompt) for the context window.
7210    /// This ensures we don't have redundant system blocks and prevents Jinja crashes.
7211    fn context_window_slice(&self) -> Vec<ChatMessage> {
7212        let mut result = Vec::with_capacity(self.history.len().saturating_sub(1));
7213
7214        // Skip index 0 (the raw system message) and any stray system messages in history.
7215        if self.history.len() > 1 {
7216            for m in &self.history[1..] {
7217                if m.role == "system" {
7218                    continue;
7219                }
7220
7221                let mut sanitized = m.clone();
7222                // DEEP SANITIZE: LM Studio Jinja templates for Qwen crash on truly empty content.
7223                if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
7224                    sanitized.content = MessageContent::Text(" ".into());
7225                }
7226                result.push(sanitized);
7227            }
7228        }
7229
7230        // Jinja Guard: The first message after the system prompt MUST be 'user'.
7231        // If not (e.g. because of compaction), we insert a tiny anchor.
7232        if !result.is_empty() && result[0].role != "user" {
7233            result.insert(0, ChatMessage::user("Continuing previous context..."));
7234        }
7235
7236        result
7237    }
7238
7239    fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
7240        let mut result = Vec::with_capacity(self.history.len().saturating_sub(start_idx.max(1)));
7241
7242        if self.history.len() > 1 {
7243            let start = start_idx.max(1).min(self.history.len());
7244            for m in &self.history[start..] {
7245                if m.role == "system" {
7246                    continue;
7247                }
7248
7249                let mut sanitized = m.clone();
7250                if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
7251                    sanitized.content = MessageContent::Text(" ".into());
7252                }
7253                result.push(sanitized);
7254            }
7255        }
7256
7257        if !result.is_empty() && result[0].role != "user" {
7258            result.insert(0, ChatMessage::user("Continuing current plan execution..."));
7259        }
7260
7261        result
7262    }
7263
7264    /// Drop old turns from the middle of history.
7265    fn trim_history(&mut self, max_messages: usize) {
7266        if self.history.len() <= max_messages {
7267            return;
7268        }
7269        // Always keep [0] (system prompt).
7270        let excess = self.history.len() - max_messages;
7271        self.history.drain(1..=excess);
7272    }
7273
7274    /// P1: Attempt to fix malformed JSON tool arguments by asking the model to re-output them.
7275    #[allow(dead_code)]
7276    async fn repair_tool_args(
7277        &self,
7278        tool_name: &str,
7279        bad_json: &str,
7280        tx: &mpsc::Sender<InferenceEvent>,
7281    ) -> Result<Value, String> {
7282        let _ = tx
7283            .send(InferenceEvent::Thought(format!(
7284                "Attempting to repair malformed JSON for '{}'...",
7285                tool_name
7286            )))
7287            .await;
7288
7289        let prompt = format!(
7290            "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.",
7291            tool_name, bad_json
7292        );
7293
7294        let messages = vec![
7295            ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
7296            ChatMessage::user(&prompt),
7297        ];
7298
7299        // Use fast model for speed if available.
7300        let (text, _, _, _) = self
7301            .engine
7302            .call_with_tools(&messages, &[], self.fast_model.as_deref())
7303            .await
7304            .map_err(|e| e.to_string())?;
7305
7306        let cleaned = text
7307            .unwrap_or_default()
7308            .trim()
7309            .trim_start_matches("```json")
7310            .trim_start_matches("```")
7311            .trim_end_matches("```")
7312            .trim()
7313            .to_string();
7314
7315        serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
7316    }
7317
7318    /// P2: Run a fast validation step after file writes to check for subtle logic errors.
7319    async fn run_critic_check(
7320        &self,
7321        path: &str,
7322        content: &str,
7323        tx: &mpsc::Sender<InferenceEvent>,
7324    ) -> Option<String> {
7325        // Only run for source code files.
7326        let ext = std::path::Path::new(path)
7327            .extension()
7328            .and_then(|e| e.to_str())
7329            .unwrap_or("");
7330        const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
7331        if !CRITIC_EXTS.contains(&ext) {
7332            return None;
7333        }
7334
7335        let _ = tx
7336            .send(InferenceEvent::Thought(format!(
7337                "CRITIC: Reviewing changes to '{}'...",
7338                path
7339            )))
7340            .await;
7341
7342        let truncated = cap_output(content, 4000);
7343
7344        const WEB_EXTS_CRITIC: &[&str] = &[
7345            "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
7346        ];
7347        let is_web_file = WEB_EXTS_CRITIC.contains(&ext);
7348
7349        let prompt = if is_web_file {
7350            format!(
7351                "You are a senior web developer doing a quality review of '{}'. \
7352                Identify ONLY real problems — missing, broken, or incomplete things that would \
7353                make this file not work or look bad in production. Check:\n\
7354                - HTML: missing DOCTYPE/charset/title/viewport meta, broken links, missing aria, unsemantic structure\n\
7355                - CSS: hardcoded px instead of responsive units, missing mobile media queries, class names used in HTML but not defined here\n\
7356                - JS/TS: missing error handling, undefined variables, console.log left in, DOM elements referenced that may not exist\n\
7357                - All: placeholder text/colors/lorem-ipsum left in, TODO comments, empty sections\n\
7358                Be extremely concise. List issues as short bullets. If everything is production-ready, output 'PASS'.\n\n\
7359                ```{}\n{}\n```",
7360                path, ext, truncated
7361            )
7362        } else {
7363            format!(
7364                "You are a Senior Security and Code Quality auditor. Review this file content for '{}' \
7365                and identify any critical logic errors, security vulnerabilities, or missing error handling. \
7366                Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
7367                path, ext, truncated
7368            )
7369        };
7370
7371        let messages = vec![
7372            ChatMessage::system("You are a technical critic. Identify ONLY real issues that need fixing. Output 'PASS' if none found."),
7373            ChatMessage::user(&prompt)
7374        ];
7375
7376        let (text, _, _, _) = self
7377            .engine
7378            .call_with_tools(&messages, &[], self.fast_model.as_deref())
7379            .await
7380            .ok()?;
7381
7382        let critique = text?.trim().to_string();
7383        if critique.to_uppercase().contains("PASS") || critique.is_empty() {
7384            None
7385        } else {
7386            Some(critique)
7387        }
7388    }
7389}
7390
7391// ── Tool dispatcher ───────────────────────────────────────────────────────────
7392
7393pub async fn dispatch_tool(
7394    name: &str,
7395    args: &Value,
7396    config: &crate::agent::config::HematiteConfig,
7397    budget_tokens: usize,
7398) -> Result<String, String> {
7399    dispatch_builtin_tool(name, args, config, budget_tokens).await
7400}
7401
7402fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
7403    let trimmed = text.trim();
7404    let stripped = trimmed
7405        .strip_prefix("/think")
7406        .or_else(|| trimmed.strip_prefix("/no_think"))
7407        .map(str::trim)
7408        .unwrap_or(trimmed)
7409        .trim_start_matches('\n')
7410        .trim();
7411    (!stripped.is_empty()).then(|| stripped.to_string())
7412}
7413
7414fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
7415    if tool_name != "inspect_host" {
7416        return;
7417    }
7418
7419    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7420        return;
7421    };
7422    if topic != "fix_plan" {
7423        return;
7424    }
7425
7426    let issue_missing = args
7427        .get("issue")
7428        .and_then(|v| v.as_str())
7429        .map(str::trim)
7430        .is_none_or(|value| value.is_empty());
7431    if !issue_missing {
7432        return;
7433    }
7434
7435    let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
7436        return;
7437    };
7438
7439    let Value::Object(map) = args else {
7440        return;
7441    };
7442    map.insert(
7443        "issue".to_string(),
7444        Value::String(fallback_issue.to_string()),
7445    );
7446}
7447
7448fn fill_missing_dns_lookup_name(
7449    tool_name: &str,
7450    args: &mut Value,
7451    latest_user_prompt: Option<&str>,
7452) {
7453    if tool_name != "inspect_host" {
7454        return;
7455    }
7456
7457    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7458        return;
7459    };
7460    if topic != "dns_lookup" {
7461        return;
7462    }
7463
7464    let name_missing = args
7465        .get("name")
7466        .and_then(|v| v.as_str())
7467        .map(str::trim)
7468        .is_none_or(|value| value.is_empty());
7469    if !name_missing {
7470        return;
7471    }
7472
7473    let Some(prompt) = latest_user_prompt else {
7474        return;
7475    };
7476    let Some(name) = extract_dns_lookup_target_from_text(prompt) else {
7477        return;
7478    };
7479
7480    let Value::Object(map) = args else {
7481        return;
7482    };
7483    map.insert("name".to_string(), Value::String(name));
7484}
7485
7486fn fill_missing_dns_lookup_type(
7487    tool_name: &str,
7488    args: &mut Value,
7489    latest_user_prompt: Option<&str>,
7490) {
7491    if tool_name != "inspect_host" {
7492        return;
7493    }
7494
7495    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7496        return;
7497    };
7498    if topic != "dns_lookup" {
7499        return;
7500    }
7501
7502    let type_missing = args
7503        .get("type")
7504        .and_then(|v| v.as_str())
7505        .map(str::trim)
7506        .is_none_or(|value| value.is_empty());
7507    if !type_missing {
7508        return;
7509    }
7510
7511    let record_type = latest_user_prompt
7512        .and_then(extract_dns_record_type_from_text)
7513        .unwrap_or("A");
7514
7515    let Value::Object(map) = args else {
7516        return;
7517    };
7518    map.insert("type".to_string(), Value::String(record_type.to_string()));
7519}
7520
7521fn fill_missing_event_query_args(
7522    tool_name: &str,
7523    args: &mut Value,
7524    latest_user_prompt: Option<&str>,
7525) {
7526    if tool_name != "inspect_host" {
7527        return;
7528    }
7529
7530    let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7531        return;
7532    };
7533    if topic != "event_query" {
7534        return;
7535    }
7536
7537    let Some(prompt) = latest_user_prompt else {
7538        return;
7539    };
7540
7541    let Value::Object(map) = args else {
7542        return;
7543    };
7544
7545    let event_id_missing = map.get("event_id").and_then(|v| v.as_u64()).is_none();
7546    if event_id_missing {
7547        if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7548            map.insert(
7549                "event_id".to_string(),
7550                Value::Number(serde_json::Number::from(event_id)),
7551            );
7552        }
7553    }
7554
7555    let log_missing = map
7556        .get("log")
7557        .and_then(|v| v.as_str())
7558        .map(str::trim)
7559        .is_none_or(|value| value.is_empty());
7560    if log_missing {
7561        if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7562            map.insert("log".to_string(), Value::String(log_name.to_string()));
7563        }
7564    }
7565
7566    let level_missing = map
7567        .get("level")
7568        .and_then(|v| v.as_str())
7569        .map(str::trim)
7570        .is_none_or(|value| value.is_empty());
7571    if level_missing {
7572        if let Some(level) = extract_event_query_level_from_text(prompt) {
7573            map.insert("level".to_string(), Value::String(level.to_string()));
7574        }
7575    }
7576
7577    let hours_missing = map.get("hours").and_then(|v| v.as_u64()).is_none();
7578    if hours_missing {
7579        if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7580            map.insert(
7581                "hours".to_string(),
7582                Value::Number(serde_json::Number::from(hours)),
7583            );
7584        }
7585    }
7586}
7587
7588fn should_rewrite_shell_to_fix_plan(
7589    tool_name: &str,
7590    args: &Value,
7591    latest_user_prompt: Option<&str>,
7592) -> bool {
7593    if tool_name != "shell" {
7594        return false;
7595    }
7596    let Some(prompt) = latest_user_prompt else {
7597        return false;
7598    };
7599    if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
7600        return false;
7601    }
7602    let command = args
7603        .get("command")
7604        .and_then(|value| value.as_str())
7605        .unwrap_or("");
7606    shell_looks_like_structured_host_inspection(command)
7607}
7608
7609fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
7610    use std::sync::OnceLock;
7611    static RE_VERSION: OnceLock<regex::Regex> = OnceLock::new();
7612    static RE_BUMP: OnceLock<regex::Regex> = OnceLock::new();
7613    let re = match flag {
7614        "-Version" => RE_VERSION.get_or_init(|| {
7615            regex::Regex::new(r#"(?i)-Version\s+['"]?([^'" \r\n]+)['"]?"#).expect("valid")
7616        }),
7617        "-Bump" => RE_BUMP.get_or_init(|| {
7618            regex::Regex::new(r#"(?i)-Bump\s+['"]?([^'" \r\n]+)['"]?"#).expect("valid")
7619        }),
7620        other => {
7621            let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(other));
7622            return regex::Regex::new(&pattern).ok().and_then(|re| {
7623                re.captures(command)
7624                    .and_then(|c| c.get(1))
7625                    .map(|m| m.as_str().to_string())
7626            });
7627        }
7628    };
7629    re.captures(command)?.get(1).map(|m| m.as_str().to_string())
7630}
7631
7632fn clean_shell_dns_token(token: &str) -> String {
7633    token
7634        .trim_matches(|c: char| {
7635            c.is_whitespace()
7636                || matches!(
7637                    c,
7638                    '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ';' | ',' | '`'
7639                )
7640        })
7641        .trim_end_matches([':', '.'])
7642        .to_string()
7643}
7644
7645fn looks_like_dns_target(token: &str) -> bool {
7646    let cleaned = clean_shell_dns_token(token);
7647    if cleaned.is_empty() {
7648        return false;
7649    }
7650
7651    let lower = cleaned.to_ascii_lowercase();
7652    if matches!(
7653        lower.as_str(),
7654        "a" | "aaaa"
7655            | "mx"
7656            | "srv"
7657            | "txt"
7658            | "cname"
7659            | "ptr"
7660            | "soa"
7661            | "any"
7662            | "resolve-dnsname"
7663            | "nslookup"
7664            | "host"
7665            | "dig"
7666            | "powershell"
7667            | "-command"
7668            | "foreach-object"
7669            | "select-object"
7670            | "address"
7671            | "ipaddress"
7672            | "name"
7673            | "type"
7674    ) {
7675        return false;
7676    }
7677
7678    if lower == "localhost" || cleaned.parse::<std::net::IpAddr>().is_ok() {
7679        return true;
7680    }
7681
7682    cleaned.contains('.')
7683        && cleaned
7684            .chars()
7685            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '*'))
7686}
7687
7688fn dns_quoted_re() -> &'static regex::Regex {
7689    use std::sync::OnceLock;
7690    static RE: OnceLock<regex::Regex> = OnceLock::new();
7691    RE.get_or_init(|| regex::Regex::new(r#"['"]([^'"]+)['"]"#).expect("valid"))
7692}
7693
7694fn extract_dns_lookup_target_from_shell(command: &str) -> Option<String> {
7695    use std::sync::OnceLock;
7696    static RE1: OnceLock<regex::Regex> = OnceLock::new();
7697    static RE2: OnceLock<regex::Regex> = OnceLock::new();
7698    static RE3: OnceLock<regex::Regex> = OnceLock::new();
7699    let re1 = RE1.get_or_init(|| {
7700        regex::Regex::new(r#"(?i)-name\s+['"]?([^'"\s;()]+)['"]?"#).expect("valid")
7701    });
7702    let re2 = RE2.get_or_init(|| {
7703        regex::Regex::new(r#"(?i)(?:gethostaddresses|gethostentry)\s*\(\s*['"]([^'"]+)['"]\s*\)"#)
7704            .expect("valid")
7705    });
7706    let re3 = RE3.get_or_init(|| {
7707        regex::Regex::new(
7708            r#"(?i)\b(?:resolve-dnsname|nslookup|host|dig)\s+['"]?([^'"\s;()]+)['"]?"#,
7709        )
7710        .expect("valid")
7711    });
7712    for re in [re1, re2, re3] {
7713        if let Some(value) = re
7714            .captures(command)
7715            .and_then(|captures| captures.get(1).map(|m| clean_shell_dns_token(m.as_str())))
7716            .filter(|value| looks_like_dns_target(value))
7717        {
7718            return Some(value);
7719        }
7720    }
7721
7722    let quoted = dns_quoted_re();
7723    for captures in quoted.captures_iter(command) {
7724        let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7725        if looks_like_dns_target(&candidate) {
7726            return Some(candidate);
7727        }
7728    }
7729
7730    command
7731        .split_whitespace()
7732        .map(clean_shell_dns_token)
7733        .find(|token| looks_like_dns_target(token))
7734}
7735
7736fn extract_dns_lookup_target_from_text(text: &str) -> Option<String> {
7737    let quoted = dns_quoted_re();
7738    for captures in quoted.captures_iter(text) {
7739        let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7740        if looks_like_dns_target(&candidate) {
7741            return Some(candidate);
7742        }
7743    }
7744
7745    text.split_whitespace()
7746        .map(clean_shell_dns_token)
7747        .find(|token| looks_like_dns_target(token))
7748}
7749
7750fn extract_dns_record_type_from_text(text: &str) -> Option<&'static str> {
7751    let lower = text.to_ascii_lowercase();
7752    if lower.contains("aaaa record") || lower.contains("ipv6 address") {
7753        Some("AAAA")
7754    } else if lower.contains("mx record") {
7755        Some("MX")
7756    } else if lower.contains("srv record") {
7757        Some("SRV")
7758    } else if lower.contains("txt record") {
7759        Some("TXT")
7760    } else if lower.contains("cname record") {
7761        Some("CNAME")
7762    } else if lower.contains("soa record") {
7763        Some("SOA")
7764    } else if lower.contains("ptr record") {
7765        Some("PTR")
7766    } else if lower.contains("a record")
7767        || (lower.contains("ip address") && lower.contains(" of "))
7768        || (lower.contains("what") && lower.contains("ip") && lower.contains("for"))
7769    {
7770        Some("A")
7771    } else {
7772        None
7773    }
7774}
7775
7776fn extract_event_query_event_id_from_text(text: &str) -> Option<u32> {
7777    use std::sync::OnceLock;
7778    static RE: OnceLock<regex::Regex> = OnceLock::new();
7779    let re = RE.get_or_init(|| {
7780        regex::Regex::new(r"(?i)\bevent(?:\s*_?\s*id)?\s*[:#]?\s*(\d{2,5})\b").expect("valid")
7781    });
7782    re.captures(text)
7783        .and_then(|captures| captures.get(1))
7784        .and_then(|m| m.as_str().parse::<u32>().ok())
7785}
7786
7787fn extract_event_query_log_from_text(text: &str) -> Option<&'static str> {
7788    let lower = text.to_ascii_lowercase();
7789    if lower.contains("security log") {
7790        Some("Security")
7791    } else if lower.contains("application log") {
7792        Some("Application")
7793    } else if lower.contains("system log") || lower.contains("system errors") {
7794        Some("System")
7795    } else if lower.contains("setup log") {
7796        Some("Setup")
7797    } else {
7798        None
7799    }
7800}
7801
7802fn extract_event_query_level_from_text(text: &str) -> Option<&'static str> {
7803    let lower = text.to_ascii_lowercase();
7804    if lower.contains("critical") {
7805        Some("Critical")
7806    } else if lower.contains("error") || lower.contains("errors") {
7807        Some("Error")
7808    } else if lower.contains("warning") || lower.contains("warnings") || lower.contains("warn") {
7809        Some("Warning")
7810    } else if lower.contains("information")
7811        || lower.contains("informational")
7812        || lower.contains("info")
7813    {
7814        Some("Information")
7815    } else {
7816        None
7817    }
7818}
7819
7820fn extract_event_query_hours_from_text(text: &str) -> Option<u32> {
7821    use std::sync::OnceLock;
7822    static RE: OnceLock<regex::Regex> = OnceLock::new();
7823    let lower = text.to_ascii_lowercase();
7824    let re = RE.get_or_init(|| {
7825        regex::Regex::new(r"(?i)\b(?:last|past)\s+(\d{1,3})\s*(hour|hours|hr|hrs)\b")
7826            .expect("valid")
7827    });
7828    if let Some(hours) = re
7829        .captures(&lower)
7830        .and_then(|captures| captures.get(1))
7831        .and_then(|m| m.as_str().parse::<u32>().ok())
7832    {
7833        return Some(hours);
7834    }
7835    if lower.contains("last hour") || lower.contains("past hour") {
7836        Some(1)
7837    } else if lower.contains("today") {
7838        Some(24)
7839    } else {
7840        None
7841    }
7842}
7843
7844fn extract_dns_record_type_from_shell(command: &str) -> Option<&'static str> {
7845    let lower = command.to_ascii_lowercase();
7846    if lower.contains("-type aaaa") || lower.contains("-type=aaaa") {
7847        Some("AAAA")
7848    } else if lower.contains("-type mx") || lower.contains("-type=mx") {
7849        Some("MX")
7850    } else if lower.contains("-type srv") || lower.contains("-type=srv") {
7851        Some("SRV")
7852    } else if lower.contains("-type txt") || lower.contains("-type=txt") {
7853        Some("TXT")
7854    } else if lower.contains("-type cname") || lower.contains("-type=cname") {
7855        Some("CNAME")
7856    } else if lower.contains("-type soa") || lower.contains("-type=soa") {
7857        Some("SOA")
7858    } else if lower.contains("-type ptr") || lower.contains("-type=ptr") {
7859        Some("PTR")
7860    } else if lower.contains("-type a") || lower.contains("-type=a") {
7861        Some("A")
7862    } else {
7863        extract_dns_record_type_from_text(command)
7864    }
7865}
7866
7867fn host_inspection_args_from_prompt(topic: &str, prompt: &str) -> Value {
7868    let mut args = serde_json::json!({ "topic": topic });
7869    if let Some(obj) = args.as_object_mut() {
7870        if topic == "dns_lookup" {
7871            if let Some(name) = extract_dns_lookup_target_from_text(prompt) {
7872                obj.insert("name".to_string(), Value::String(name));
7873            }
7874            let record_type = extract_dns_record_type_from_text(prompt).unwrap_or("A");
7875            obj.insert("type".to_string(), Value::String(record_type.to_string()));
7876        } else if topic == "event_query" {
7877            if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7878                obj.insert(
7879                    "event_id".to_string(),
7880                    Value::Number(serde_json::Number::from(event_id)),
7881                );
7882            }
7883            if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7884                obj.insert("log".to_string(), Value::String(log_name.to_string()));
7885            }
7886            if let Some(level) = extract_event_query_level_from_text(prompt) {
7887                obj.insert("level".to_string(), Value::String(level.to_string()));
7888            }
7889            if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7890                obj.insert(
7891                    "hours".to_string(),
7892                    Value::Number(serde_json::Number::from(hours)),
7893                );
7894            }
7895        }
7896    }
7897    args
7898}
7899
7900fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7901    let workflow = preferred_maintainer_workflow(prompt)?;
7902    let lower = prompt.to_ascii_lowercase();
7903    match workflow {
7904        "clean" => Some(serde_json::json!({
7905            "workflow": "clean",
7906            "deep": lower.contains("deep clean")
7907                || lower.contains("deep cleanup")
7908                || lower.contains("deep"),
7909            "reset": lower.contains("reset"),
7910            "prune_dist": lower.contains("prune dist")
7911                || lower.contains("prune old dist")
7912                || lower.contains("prune old artifacts")
7913                || lower.contains("old dist artifacts")
7914                || lower.contains("old artifacts"),
7915        })),
7916        "package_windows" => Some(serde_json::json!({
7917            "workflow": "package_windows",
7918            "installer": lower.contains("installer") || lower.contains("setup.exe"),
7919            "add_to_path": lower.contains("addtopath")
7920                || lower.contains("add to path")
7921                || lower.contains("update path")
7922                || lower.contains("refresh path"),
7923        })),
7924        "release" => {
7925            use std::sync::OnceLock;
7926            static SEMVER_RE: OnceLock<regex::Regex> = OnceLock::new();
7927            let version = SEMVER_RE
7928                .get_or_init(|| regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#).expect("valid"))
7929                .captures(prompt)
7930                .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
7931            let bump = if lower.contains("patch") {
7932                Some("patch")
7933            } else if lower.contains("minor") {
7934                Some("minor")
7935            } else if lower.contains("major") {
7936                Some("major")
7937            } else {
7938                None
7939            };
7940            let mut args = serde_json::json!({
7941                "workflow": "release",
7942                "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
7943                "add_to_path": lower.contains("addtopath")
7944                    || lower.contains("add to path")
7945                    || lower.contains("update path"),
7946                "skip_installer": lower.contains("skip installer"),
7947                "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
7948                "publish_voice_crate": lower.contains("publish voice crate")
7949                    || lower.contains("publish hematite-kokoros"),
7950            });
7951            if let Some(version) = version {
7952                args["version"] = Value::String(version);
7953            }
7954            if let Some(bump) = bump {
7955                args["bump"] = Value::String(bump.to_string());
7956            }
7957            Some(args)
7958        }
7959        _ => None,
7960    }
7961}
7962
7963fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7964    if is_scaffold_request(prompt) {
7965        return None;
7966    }
7967    let workflow = preferred_workspace_workflow(prompt)?;
7968    let lower = prompt.to_ascii_lowercase();
7969    let trimmed = prompt.trim();
7970
7971    if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
7972        return Some(serde_json::json!({
7973            "workflow": "command",
7974            "command": command,
7975        }));
7976    }
7977
7978    if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
7979        return Some(serde_json::json!({
7980            "workflow": "script_path",
7981            "path": path,
7982        }));
7983    }
7984
7985    match workflow {
7986        "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
7987            "workflow": workflow,
7988        })),
7989        "script" => {
7990            let package_script = if lower.contains("npm run ") {
7991                extract_word_after(&lower, "npm run ")
7992            } else if lower.contains("pnpm run ") {
7993                extract_word_after(&lower, "pnpm run ")
7994            } else if lower.contains("bun run ") {
7995                extract_word_after(&lower, "bun run ")
7996            } else if lower.contains("yarn ") {
7997                extract_word_after(&lower, "yarn ")
7998            } else {
7999                None
8000            };
8001
8002            if let Some(name) = package_script {
8003                return Some(serde_json::json!({
8004                    "workflow": "package_script",
8005                    "name": name,
8006                }));
8007            }
8008
8009            if let Some(name) = extract_word_after(&lower, "just ") {
8010                return Some(serde_json::json!({
8011                    "workflow": "just",
8012                    "name": name,
8013                }));
8014            }
8015            if let Some(name) = extract_word_after(&lower, "make ") {
8016                return Some(serde_json::json!({
8017                    "workflow": "make",
8018                    "name": name,
8019                }));
8020            }
8021            if let Some(name) = extract_word_after(&lower, "task ") {
8022                return Some(serde_json::json!({
8023                    "workflow": "task",
8024                    "name": name,
8025                }));
8026            }
8027
8028            None
8029        }
8030        _ => None,
8031    }
8032}
8033
8034fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
8035    let lower = prompt.to_ascii_lowercase();
8036    for prefix in [
8037        "cargo ",
8038        "npm ",
8039        "pnpm ",
8040        "yarn ",
8041        "bun ",
8042        "pytest",
8043        "go build",
8044        "go test",
8045        "make ",
8046        "just ",
8047        "task ",
8048        "./gradlew",
8049        ".\\gradlew",
8050    ] {
8051        if let Some(index) = lower.find(prefix) {
8052            return Some(prompt[index..].trim().trim_matches('`').to_string());
8053        }
8054    }
8055    None
8056}
8057
8058fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
8059    let normalized = prompt.replace('\\', "/");
8060    for token in normalized.split_whitespace() {
8061        let candidate = token
8062            .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
8063            .trim_start_matches("./");
8064        if candidate.starts_with("scripts/")
8065            && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
8066                .iter()
8067                .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
8068        {
8069            return Some(candidate.to_string());
8070        }
8071    }
8072    None
8073}
8074
8075fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
8076    let start = haystack.find(prefix)? + prefix.len();
8077    let tail = &haystack[start..];
8078    let word = tail
8079        .split_whitespace()
8080        .next()
8081        .map(str::trim)
8082        .filter(|value| !value.is_empty())?;
8083    Some(
8084        word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
8085            .to_string(),
8086    )
8087}
8088
8089fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
8090    let lower = command.to_ascii_lowercase();
8091    if lower.contains("clean.ps1") {
8092        return Some(serde_json::json!({
8093            "workflow": "clean",
8094            "deep": lower.contains("-deep"),
8095            "reset": lower.contains("-reset"),
8096            "prune_dist": lower.contains("-prunedist"),
8097        }));
8098    }
8099    if lower.contains("package-windows.ps1") {
8100        return Some(serde_json::json!({
8101            "workflow": "package_windows",
8102            "installer": lower.contains("-installer"),
8103            "add_to_path": lower.contains("-addtopath"),
8104        }));
8105    }
8106    if lower.contains("release.ps1") {
8107        let version = extract_release_arg(command, "-Version");
8108        let bump = extract_release_arg(command, "-Bump");
8109        if version.is_none() && bump.is_none() {
8110            return Some(serde_json::json!({
8111                "workflow": "release"
8112            }));
8113        }
8114        let mut args = serde_json::json!({
8115            "workflow": "release",
8116            "push": lower.contains("-push"),
8117            "add_to_path": lower.contains("-addtopath"),
8118            "skip_installer": lower.contains("-skipinstaller"),
8119            "publish_crates": lower.contains("-publishcrates"),
8120            "publish_voice_crate": lower.contains("-publishvoicecrate"),
8121        });
8122        if let Some(version) = version {
8123            args["version"] = Value::String(version);
8124        }
8125        if let Some(bump) = bump {
8126            args["bump"] = Value::String(bump);
8127        }
8128        return Some(args);
8129    }
8130    None
8131}
8132
8133fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
8134    let lower = command.to_ascii_lowercase();
8135    if lower.contains("clean.ps1")
8136        || lower.contains("package-windows.ps1")
8137        || lower.contains("release.ps1")
8138    {
8139        return None;
8140    }
8141
8142    if let Some(path) = extract_workspace_script_path_from_prompt(command) {
8143        return Some(serde_json::json!({
8144            "workflow": "script_path",
8145            "path": path,
8146        }));
8147    }
8148
8149    let looks_like_workspace_command = [
8150        "cargo ",
8151        "npm ",
8152        "pnpm ",
8153        "yarn ",
8154        "bun ",
8155        "pytest",
8156        "go build",
8157        "go test",
8158        "make ",
8159        "just ",
8160        "task ",
8161        "./gradlew",
8162        ".\\gradlew",
8163    ]
8164    .iter()
8165    .any(|needle| lower.contains(needle));
8166
8167    if looks_like_workspace_command {
8168        Some(serde_json::json!({
8169            "workflow": "command",
8170            "command": command.trim(),
8171        }))
8172    } else {
8173        None
8174    }
8175}
8176
8177fn rewrite_host_tool_call(
8178    tool_name: &mut String,
8179    args: &mut Value,
8180    latest_user_prompt: Option<&str>,
8181) {
8182    if *tool_name == "shell" {
8183        let command = args
8184            .get("command")
8185            .and_then(|value| value.as_str())
8186            .unwrap_or("");
8187        if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
8188            *tool_name = "run_hematite_maintainer_workflow".to_string();
8189            *args = maintainer_workflow_args;
8190            return;
8191        }
8192        if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
8193            *tool_name = "run_workspace_workflow".to_string();
8194            *args = workspace_workflow_args;
8195            return;
8196        }
8197    }
8198    let is_surgical_tool = matches!(
8199        tool_name.as_str(),
8200        "create_directory"
8201            | "write_file"
8202            | "edit_file"
8203            | "patch_hunk"
8204            | "multi_replace_file_content"
8205            | "replace_file_content"
8206            | "move_file"
8207            | "delete_file"
8208    );
8209
8210    if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
8211        if let Some(prompt_args) =
8212            latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
8213        {
8214            *tool_name = "run_hematite_maintainer_workflow".to_string();
8215            *args = prompt_args;
8216            return;
8217        }
8218    }
8219    // Only allow auto-rewrite for generic shell/command triggers.
8220    // We NEVER rewrite surgical tools (write/edit) or evidence tools (read/inspect)
8221    // because that leads to inference-hijack loops.
8222    let is_generic_command_trigger = matches!(
8223        tool_name.as_str(),
8224        "shell" | "run_command" | "workflow" | "run"
8225    );
8226    if is_generic_command_trigger && *tool_name != "run_workspace_workflow" {
8227        if let Some(prompt_args) =
8228            latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
8229        {
8230            *tool_name = "run_workspace_workflow".to_string();
8231            *args = prompt_args;
8232            return;
8233        }
8234    }
8235    if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
8236        *tool_name = "inspect_host".to_string();
8237        *args = serde_json::json!({
8238            "topic": "fix_plan"
8239        });
8240    }
8241    fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
8242    fill_missing_dns_lookup_name(tool_name, args, latest_user_prompt);
8243    fill_missing_dns_lookup_type(tool_name, args, latest_user_prompt);
8244    fill_missing_event_query_args(tool_name, args, latest_user_prompt);
8245}
8246
8247fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
8248    format!(
8249        "{}:{}",
8250        tool_name,
8251        serde_json::to_string(args).unwrap_or_default()
8252    )
8253}
8254
8255fn normalized_tool_call_for_execution(
8256    tool_name: &str,
8257    raw_arguments: &Value,
8258    gemma4_model: bool,
8259    latest_user_prompt: Option<&str>,
8260) -> (String, Value) {
8261    let mut normalized_name = tool_name.to_string();
8262    let mut args = if gemma4_model {
8263        let raw_str = raw_arguments.to_string();
8264        let normalized_str =
8265            crate::agent::inference::normalize_tool_argument_string(tool_name, &raw_str);
8266        serde_json::from_str::<Value>(&normalized_str).unwrap_or_else(|_| raw_arguments.clone())
8267    } else {
8268        raw_arguments.clone()
8269    };
8270    rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
8271    (normalized_name, args)
8272}
8273
8274#[cfg(test)]
8275fn normalized_tool_call_key_for_dedupe(
8276    tool_name: &str,
8277    raw_arguments: &str,
8278    gemma4_model: bool,
8279    latest_user_prompt: Option<&str>,
8280) -> String {
8281    let val = serde_json::from_str(raw_arguments).unwrap_or(Value::Null);
8282    let (normalized_name, args) =
8283        normalized_tool_call_for_execution(tool_name, &val, gemma4_model, latest_user_prompt);
8284    canonical_tool_call_key(&normalized_name, &args)
8285}
8286
8287impl ConversationManager {
8288    /// Checks if a tool call is authorized given the current configuration and mode.
8289    fn check_authorization(
8290        &self,
8291        name: &str,
8292        args: &serde_json::Value,
8293        config: &crate::agent::config::HematiteConfig,
8294        yolo_flag: bool,
8295    ) -> crate::agent::permission_enforcer::AuthorizationDecision {
8296        crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
8297    }
8298
8299    /// Layer 4: Isolated tool execution logic. Does not mutate 'self' to allow parallelism.
8300    async fn process_tool_call(
8301        &self,
8302        mut call: ToolCallFn,
8303        config: crate::agent::config::HematiteConfig,
8304        yolo: bool,
8305        tx: mpsc::Sender<InferenceEvent>,
8306        real_id: String,
8307        budget_tokens: usize,
8308    ) -> ToolExecutionOutcome {
8309        let mut msg_results = Vec::with_capacity(2);
8310        let mut latest_target_dir = None;
8311        let mut plan_drafted_this_turn = false;
8312        let mut parsed_plan_handoff = None;
8313        let gemma4_model =
8314            crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
8315        let (normalized_name, mut args) = normalized_tool_call_for_execution(
8316            &call.name,
8317            &call.arguments,
8318            gemma4_model,
8319            self.history
8320                .last()
8321                .and_then(|m| m.content.as_str().split('\n').next_back()),
8322        );
8323        call.name = normalized_name;
8324        let last_user_prompt = self
8325            .history
8326            .iter()
8327            .rev()
8328            .find(|message| message.role == "user")
8329            .map(|message| message.content.as_str());
8330        rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
8331        if self
8332            .plan_execution_active
8333            .load(std::sync::atomic::Ordering::SeqCst)
8334        {
8335            let fallback_target = self
8336                .session_memory
8337                .current_plan
8338                .as_ref()
8339                .and_then(|plan| plan.target_files.first().map(String::as_str));
8340            let explicit_query = last_user_prompt.and_then(extract_explicit_web_search_query);
8341            if let Some((repaired_args, note)) = repaired_plan_tool_args(
8342                &call.name,
8343                &args,
8344                std::path::Path::new(".hematite/TASK.md").exists(),
8345                fallback_target,
8346                explicit_query.as_deref(),
8347            ) {
8348                args = repaired_args;
8349                let _ = tx.send(InferenceEvent::Thought(note)).await;
8350            }
8351        }
8352
8353        let display = format_tool_display(&call.name, &args);
8354        let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
8355        let auth = self.check_authorization(&call.name, &args, &config, yolo);
8356
8357        // 2. Permission Check
8358        let decision_result = match precondition_result {
8359            Err(e) => Err(e),
8360            Ok(_) => match auth {
8361                crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
8362                crate::agent::permission_enforcer::AuthorizationDecision::Ask {
8363                    reason,
8364                    source: _,
8365                } => {
8366                    let mutation_label =
8367                        crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8368                    let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
8369                    let _ = tx
8370                        .send(InferenceEvent::ApprovalRequired {
8371                            id: real_id.clone(),
8372                            name: call.name.clone(),
8373                            display: format!("{}\nWhy: {}", display, reason),
8374                            diff: None,
8375                            mutation_label,
8376                            responder: approve_tx,
8377                        })
8378                        .await;
8379
8380                    match approve_rx.await {
8381                        Ok(true) => Ok(()),
8382                        _ => Err("Declined by user".into()),
8383                    }
8384                }
8385                crate::agent::permission_enforcer::AuthorizationDecision::Deny {
8386                    reason, ..
8387                } => Err(reason),
8388            },
8389        };
8390        let blocked_by_policy =
8391            matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
8392
8393        // 3. Execution (Local or MCP)
8394        let (output, is_error) = match decision_result {
8395            Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
8396            Err(e) => (format!("Error: {}", e), true),
8397            Ok(_) => {
8398                let _ = tx
8399                    .send(InferenceEvent::ToolCallStart {
8400                        id: real_id.clone(),
8401                        name: call.name.clone(),
8402                        args: display.clone(),
8403                    })
8404                    .await;
8405
8406                let result = if call.name.starts_with("lsp_") {
8407                    let lsp = self.lsp_manager.clone();
8408                    let path = args
8409                        .get("path")
8410                        .and_then(|v| v.as_str())
8411                        .unwrap_or("")
8412                        .to_string();
8413                    let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8414                    let character =
8415                        args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8416
8417                    match call.name.as_str() {
8418                        "lsp_definitions" => {
8419                            crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
8420                                .await
8421                        }
8422                        "lsp_references" => {
8423                            crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
8424                                .await
8425                        }
8426                        "lsp_hover" => {
8427                            crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
8428                        }
8429                        "lsp_search_symbol" => {
8430                            let query = args
8431                                .get("query")
8432                                .and_then(|v| v.as_str())
8433                                .unwrap_or_default()
8434                                .to_string();
8435                            crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
8436                        }
8437                        "lsp_rename_symbol" => {
8438                            let new_name = args
8439                                .get("new_name")
8440                                .and_then(|v| v.as_str())
8441                                .unwrap_or_default()
8442                                .to_string();
8443                            crate::tools::lsp_tools::lsp_rename_symbol(
8444                                lsp, path, line, character, new_name,
8445                            )
8446                            .await
8447                        }
8448                        "lsp_get_diagnostics" => {
8449                            crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
8450                        }
8451                        _ => Err(format!("Unknown LSP tool: {}", call.name)),
8452                    }
8453                } else if call.name == "auto_pin_context" {
8454                    let pts = args.get("paths").and_then(|v| v.as_array());
8455                    let reason = args
8456                        .get("reason")
8457                        .and_then(|v| v.as_str())
8458                        .unwrap_or("uninformed scoping");
8459                    if let Some(arr) = pts {
8460                        let mut pinned = Vec::with_capacity(arr.len().min(3));
8461                        {
8462                            let mut guard = self.pinned_files.write().await;
8463                            const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; // 25MB Safety Valve
8464
8465                            for v in arr.iter().take(3) {
8466                                if let Some(p) = v.as_str() {
8467                                    if let Ok(meta) = std::fs::metadata(p) {
8468                                        if meta.len() > MAX_PINNED_SIZE {
8469                                            let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
8470                                            continue;
8471                                        }
8472                                        if let Ok(content) = std::fs::read_to_string(p) {
8473                                            guard.insert(p.to_string(), content);
8474                                            pinned.push(p.to_string());
8475                                        }
8476                                    }
8477                                }
8478                            }
8479                        }
8480                        let msg = format!(
8481                            "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
8482                            pinned.join(", "),
8483                            reason
8484                        );
8485                        let _ = tx
8486                            .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
8487                            .await;
8488                        Ok(msg)
8489                    } else {
8490                        Err("Missing 'paths' array for auto_pin_context.".to_string())
8491                    }
8492                } else if call.name == "list_pinned" {
8493                    let paths_msg = {
8494                        let pinned = self.pinned_files.read().await;
8495                        if pinned.is_empty() {
8496                            "No files are currently pinned.".to_string()
8497                        } else {
8498                            let paths: Vec<_> = pinned.keys().cloned().collect();
8499                            format!(
8500                                "Currently pinned files in active memory:\n- {}",
8501                                paths.join("\n- ")
8502                            )
8503                        }
8504                    };
8505                    Ok(paths_msg)
8506                } else if call.name.starts_with("mcp__") {
8507                    let mut mcp = self.mcp_manager.lock().await;
8508                    match mcp.call_tool(&call.name, &args).await {
8509                        Ok(res) => Ok(res),
8510                        Err(e) => Err(e.to_string()),
8511                    }
8512                } else if call.name == "swarm" {
8513                    // ── Swarm Orchestration ──
8514                    let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
8515                    let max_workers = args
8516                        .get("max_workers")
8517                        .and_then(|v| v.as_u64())
8518                        .unwrap_or(3) as usize;
8519
8520                    let mut task_objs = Vec::new();
8521                    if let Value::Array(arr) = tasks_val {
8522                        task_objs.reserve(arr.len());
8523                        for v in arr {
8524                            let id = v
8525                                .get("id")
8526                                .and_then(|x| x.as_str())
8527                                .unwrap_or("?")
8528                                .to_string();
8529                            let target = v
8530                                .get("target")
8531                                .and_then(|x| x.as_str())
8532                                .unwrap_or("?")
8533                                .to_string();
8534                            let instruction = v
8535                                .get("instruction")
8536                                .and_then(|x| x.as_str())
8537                                .unwrap_or("?")
8538                                .to_string();
8539                            task_objs.push(crate::agent::parser::WorkerTask {
8540                                id,
8541                                target,
8542                                instruction,
8543                            });
8544                        }
8545                    }
8546
8547                    if task_objs.is_empty() {
8548                        Err("No tasks provided for swarm.".to_string())
8549                    } else {
8550                        let (swarm_tx_internal, mut swarm_rx_internal) =
8551                            tokio::sync::mpsc::channel(32);
8552                        let tx_forwarder = tx.clone();
8553
8554                        // Bridge SwarmMessage -> InferenceEvent
8555                        tokio::spawn(async move {
8556                            while let Some(msg) = swarm_rx_internal.recv().await {
8557                                match msg {
8558                                    crate::agent::swarm::SwarmMessage::Progress(id, p) => {
8559                                        let _ = tx_forwarder
8560                                            .send(InferenceEvent::Thought(format!(
8561                                                "Swarm [{}]: {}% complete",
8562                                                id, p
8563                                            )))
8564                                            .await;
8565                                    }
8566                                    crate::agent::swarm::SwarmMessage::ReviewRequest {
8567                                        worker_id,
8568                                        file_path,
8569                                        before: _,
8570                                        after: _,
8571                                        tx,
8572                                    } => {
8573                                        let (approve_tx, approve_rx) =
8574                                            tokio::sync::oneshot::channel::<bool>();
8575                                        let display = format!(
8576                                            "Swarm worker [{}]: Integrated changes into {:?}",
8577                                            worker_id, file_path
8578                                        );
8579                                        let _ = tx_forwarder
8580                                            .send(InferenceEvent::ApprovalRequired {
8581                                                id: format!("swarm_{}", worker_id),
8582                                                name: "swarm_apply".to_string(),
8583                                                display,
8584                                                diff: None,
8585                                                mutation_label: Some(
8586                                                    "Swarm Agentic Integration".to_string(),
8587                                                ),
8588                                                responder: approve_tx,
8589                                            })
8590                                            .await;
8591                                        if let Ok(approved) = approve_rx.await {
8592                                            let response = if approved {
8593                                                crate::agent::swarm::ReviewResponse::Accept
8594                                            } else {
8595                                                crate::agent::swarm::ReviewResponse::Reject
8596                                            };
8597                                            let _ = tx.send(response);
8598                                        }
8599                                    }
8600                                    crate::agent::swarm::SwarmMessage::Done => {}
8601                                }
8602                            }
8603                        });
8604
8605                        let coordinator = self.swarm_coordinator.clone();
8606                        match coordinator
8607                            .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
8608                            .await
8609                        {
8610                            Ok(_) => Ok(
8611                                "Swarm execution completed. Check files for integration results."
8612                                    .to_string(),
8613                            ),
8614                            Err(e) => Err(format!("Swarm failure: {}", e)),
8615                        }
8616                    }
8617                } else if call.name == "vision_analyze" {
8618                    crate::tools::vision::vision_analyze(&self.engine, &args).await
8619                } else if matches!(
8620                    call.name.as_str(),
8621                    "edit_file" | "patch_hunk" | "multi_search_replace" | "write_file"
8622                ) && !yolo
8623                {
8624                    // ── Diff preview gate ─────────────────────────────────────
8625                    // Compute what the edit would look like before applying it.
8626                    // If we can build a diff, require user Y/N in the TUI.
8627                    // write_file shows the full new content as additions (new files)
8628                    // or a before/after replacement (overwriting existing files).
8629                    let diff_result = match call.name.as_str() {
8630                        "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
8631                        "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
8632                        "write_file" => crate::tools::file_ops::compute_write_file_diff(&args),
8633                        _ => crate::tools::file_ops::compute_msr_diff(&args),
8634                    };
8635                    match diff_result {
8636                        Ok(diff_text) => {
8637                            let path_label =
8638                                args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
8639                            let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
8640                            let mutation_label =
8641                                crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8642                            let _ = tx
8643                                .send(InferenceEvent::ApprovalRequired {
8644                                    id: real_id.clone(),
8645                                    name: call.name.clone(),
8646                                    display: format!("Edit preview: {}", path_label),
8647                                    diff: Some(diff_text),
8648                                    mutation_label,
8649                                    responder: appr_tx,
8650                                })
8651                                .await;
8652                            match appr_rx.await {
8653                                Ok(true) => {
8654                                    dispatch_tool(&call.name, &args, &config, budget_tokens).await
8655                                }
8656                                _ => Err("Edit declined by user.".into()),
8657                            }
8658                        }
8659                        // Diff computation failed (e.g. search string not found yet) —
8660                        // fall through and let the tool return its own error.
8661                        Err(_) => dispatch_tool(&call.name, &args, &config, budget_tokens).await,
8662                    }
8663                } else if call.name == "verify_build" {
8664                    // Stream build output line-by-line to the SPECULAR panel so
8665                    // the operator sees live compiler progress during long builds.
8666                    crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
8667                } else if call.name == "shell" {
8668                    // Stream shell output line-by-line to the SPECULAR panel so
8669                    // the operator sees live progress during long commands.
8670                    crate::tools::shell::execute_streaming(&args, tx.clone(), budget_tokens).await
8671                } else {
8672                    dispatch_tool(&call.name, &args, &config, budget_tokens).await
8673                };
8674
8675                match result {
8676                    Ok(o) => (o, false),
8677                    Err(e) => (format!("Error: {}", e), true),
8678                }
8679            }
8680        };
8681
8682        // ── Session Economics ────────────────────────────────────────────────
8683        {
8684            if let Ok(mut econ) = self.engine.economics.lock() {
8685                econ.record_tool(&call.name, !is_error);
8686            }
8687        }
8688
8689        if !is_error {
8690            if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
8691                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8692                    if call.name == "inspect_lines" {
8693                        self.record_line_inspection(path).await;
8694                    } else {
8695                        self.record_read_observation(path).await;
8696                    }
8697                }
8698            }
8699
8700            if call.name == "verify_build" {
8701                let ok = output.contains("BUILD OK")
8702                    || output.contains("BUILD SUCCESS")
8703                    || output.contains("BUILD OKAY");
8704                self.record_verify_build_result(ok, &output).await;
8705            }
8706
8707            if matches!(
8708                call.name.as_str(),
8709                "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
8710            ) || is_mcp_mutating_tool(&call.name)
8711            {
8712                if call.name == "write_file" {
8713                    if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8714                        if path.ends_with("PLAN.md") {
8715                            plan_drafted_this_turn = true;
8716                            if !is_error {
8717                                if let Some(content) = args.get("content").and_then(|v| v.as_str())
8718                                {
8719                                    let resolved = crate::tools::file_ops::resolve_candidate(path);
8720                                    let _ = crate::tools::plan::sync_plan_blueprint_for_path(
8721                                        &resolved, content,
8722                                    );
8723                                    parsed_plan_handoff =
8724                                        crate::tools::plan::parse_plan_handoff(content);
8725                                }
8726                            }
8727                        }
8728                    }
8729                }
8730                self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
8731                    .await;
8732            }
8733
8734            if call.name == "create_directory" {
8735                if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8736                    let resolved = crate::tools::file_ops::resolve_candidate(path);
8737                    latest_target_dir = Some(resolved.to_string_lossy().to_string());
8738                }
8739            }
8740
8741            if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
8742                msg_results.push(receipt);
8743            }
8744        }
8745
8746        // 4. Critic Check (Specular Tier 2)
8747        // Gated: skipped in yolo mode (fast path), only runs on code files with
8748        // substantive content to avoid burning tokens on trivial doc/config edits.
8749        if !is_error && !yolo && (call.name == "edit_file" || call.name == "write_file") {
8750            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
8751            let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
8752            let ext = std::path::Path::new(path)
8753                .extension()
8754                .and_then(|e| e.to_str())
8755                .unwrap_or("");
8756            const SKIP_EXTS: &[&str] = &[
8757                "md",
8758                "toml",
8759                "json",
8760                "txt",
8761                "yml",
8762                "yaml",
8763                "cfg",
8764                "csv",
8765                "lock",
8766                "gitignore",
8767            ];
8768            let line_count = content.lines().count();
8769            // Web files always get reviewed regardless of length — a 20-line HTML
8770            // skeleton can still be missing DOCTYPE, meta charset, or linked CSS.
8771            const WEB_EXTS: &[&str] = &[
8772                "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
8773            ];
8774            let is_web = WEB_EXTS.contains(&ext);
8775            let min_lines = if is_web { 5 } else { 50 };
8776            if !path.is_empty()
8777                && !content.is_empty()
8778                && !SKIP_EXTS.contains(&ext)
8779                && line_count >= min_lines
8780            {
8781                if let Some(critique) = self.run_critic_check(path, content, &tx).await {
8782                    msg_results.push(ChatMessage::system(&format!(
8783                        "[CRITIC AUTO-FIX REQUIRED — {}]\n\
8784                        Fix ALL issues below before sending your final response. \
8785                        Call the appropriate edit tools now.\n\n{}",
8786                        path, critique
8787                    )));
8788                }
8789            }
8790        }
8791
8792        ToolExecutionOutcome {
8793            call_id: real_id,
8794            tool_name: call.name,
8795            args,
8796            output,
8797            is_error,
8798            blocked_by_policy,
8799            msg_results,
8800            latest_target_dir,
8801            plan_drafted_this_turn,
8802            parsed_plan_handoff,
8803        }
8804    }
8805}
8806
8807/// The result of an isolated tool execution.
8808/// Used to bridge Parallel/Serial execution back to the main history.
8809struct ToolExecutionOutcome {
8810    call_id: String,
8811    tool_name: String,
8812    args: Value,
8813    output: String,
8814    is_error: bool,
8815    blocked_by_policy: bool,
8816    msg_results: Vec<ChatMessage>,
8817    latest_target_dir: Option<String>,
8818    plan_drafted_this_turn: bool,
8819    parsed_plan_handoff: Option<crate::tools::plan::PlanHandoff>,
8820}
8821
8822#[derive(Clone)]
8823struct CachedToolResult {
8824    tool_name: String,
8825}
8826
8827fn is_code_like_path(path: &str) -> bool {
8828    let ext = std::path::Path::new(path)
8829        .extension()
8830        .and_then(|e| e.to_str())
8831        .unwrap_or("")
8832        .to_ascii_lowercase();
8833    matches!(
8834        ext.as_str(),
8835        "rs" | "js"
8836            | "ts"
8837            | "tsx"
8838            | "jsx"
8839            | "py"
8840            | "go"
8841            | "java"
8842            | "c"
8843            | "cpp"
8844            | "cc"
8845            | "h"
8846            | "hpp"
8847            | "cs"
8848            | "swift"
8849            | "kt"
8850            | "kts"
8851            | "rb"
8852            | "php"
8853    )
8854}
8855
8856// ── Display helpers ───────────────────────────────────────────────────────────
8857
8858pub fn format_tool_display(name: &str, args: &Value) -> String {
8859    let get = |key: &str| -> &str { args.get(key).and_then(|v| v.as_str()).unwrap_or("") };
8860    match name {
8861        "shell" | "bash" | "powershell" => format!("$ {}", get("command")),
8862        "run_workspace_workflow" => format!("workflow: {}", get("workflow")),
8863        "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
8864        "describe_toolchain" => format!("describe toolchain {}", get("topic")),
8865        "inspect_host" => format!("inspect host {}", get("topic")),
8866        "write_file"
8867        | "read_file"
8868        | "edit_file"
8869        | "patch_hunk"
8870        | "inspect_lines"
8871        | "lsp_get_diagnostics" => format!("{} `{}`", name, get("path")),
8872        "grep_files" => format!(
8873            "grep_files pattern='{}' path='{}'",
8874            get("pattern"),
8875            get("path")
8876        ),
8877        "list_files" => format!("list_files `{}`", get("path")),
8878        "multi_search_replace" => format!("multi_search_replace `{}`", get("path")),
8879        _ => {
8880            // Keep generic debug output strictly bounded so it never desyncs the TUI scroll math
8881            let rep = format!("{} {:?}", name, args);
8882            if rep.len() > 100 {
8883                format!("{}... (truncated)", safe_head(&rep, 100))
8884            } else {
8885                rep
8886            }
8887        }
8888    }
8889}
8890
8891// ── Text utilities ────────────────────────────────────────────────────────────
8892
8893pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
8894    let lower = command.to_ascii_lowercase();
8895    [
8896        "$env:path",
8897        "pathvariable",
8898        "pip --version",
8899        "pipx --version",
8900        "winget --version",
8901        "choco",
8902        "scoop",
8903        "get-childitem",
8904        "gci ",
8905        "where.exe",
8906        "where ",
8907        "cargo --version",
8908        "rustc --version",
8909        "git --version",
8910        "node --version",
8911        "npm --version",
8912        "pnpm --version",
8913        "python --version",
8914        "python3 --version",
8915        "deno --version",
8916        "go version",
8917        "dotnet --version",
8918        "uv --version",
8919        "netstat",
8920        "findstr",
8921        "get-nettcpconnection",
8922        "tcpconnection",
8923        "listening",
8924        "ss -",
8925        "ss ",
8926        "lsof",
8927        "tasklist",
8928        "ipconfig",
8929        "get-netipconfiguration",
8930        "get-netadapter",
8931        "route print",
8932        "ifconfig",
8933        "ip addr",
8934        "ip route",
8935        "resolv.conf",
8936        "get-service",
8937        "sc query",
8938        "systemctl",
8939        "service --status-all",
8940        "get-process",
8941        "working set",
8942        "ps -eo",
8943        "ps aux",
8944        "desktop",
8945        "downloads",
8946        "get-netfirewallprofile",
8947        "win32_powerplan",
8948        "win32_operatingsystem",
8949        "win32_processor",
8950        "wmic",
8951        "loadpercentage",
8952        "totalvisiblememory",
8953        "freephysicalmemory",
8954        "get-wmiobject",
8955        "get-ciminstance",
8956        "get-cpu",
8957        "processorname",
8958        "clockspeed",
8959        "top memory",
8960        "top cpu",
8961        "resource usage",
8962        "powercfg",
8963        "uptime",
8964        "lastbootuptime",
8965        // registry reads for OS/version/update/security info — always use inspect_host
8966        "hklm:",
8967        "hkcu:",
8968        "hklm:\\",
8969        "hkcu:\\",
8970        "currentversion",
8971        "productname",
8972        "displayversion",
8973        "get-itemproperty",
8974        "get-itempropertyvalue",
8975        // updates
8976        "get-windowsupdatelog",
8977        "windowsupdatelog",
8978        "microsoft.update.session",
8979        "createupdatesearcher",
8980        "wuauserv",
8981        "usoclient",
8982        "get-hotfix",
8983        "wu_",
8984        // security / defender
8985        "get-mpcomputerstatus",
8986        "get-mppreference",
8987        "get-mpthreat",
8988        "start-mpscan",
8989        "win32_computersecurity",
8990        "softwarelicensingproduct",
8991        "enablelua",
8992        "get-netfirewallrule",
8993        "netfirewallprofile",
8994        "antivirus",
8995        "defenderstatus",
8996        // disk health / smart
8997        "get-physicaldisk",
8998        "get-disk",
8999        "get-volume",
9000        "get-psdrive",
9001        "psdrive",
9002        "manage-bde",
9003        "bitlockervolume",
9004        "get-bitlockervolume",
9005        "get-smbencryptionstatus",
9006        "smbencryption",
9007        "get-netlanmanagerconnection",
9008        "lanmanager",
9009        "msstoragedriver_failurepredic",
9010        "win32_diskdrive",
9011        "smartstatus",
9012        "diskstatus",
9013        "get-counter",
9014        "intensity",
9015        "benchmark",
9016        "thrash",
9017        "get-item",
9018        "test-path",
9019        // gpo / certs / integrity / domain
9020        "gpresult",
9021        "applied gpo",
9022        "cert:\\",
9023        "cert:",
9024        "component based servicing",
9025        "componentstore",
9026        "get-computerinfo",
9027        "win32_computersystem",
9028        // battery
9029        "win32_battery",
9030        "batterystaticdata",
9031        "batteryfullchargedcapacity",
9032        "batterystatus",
9033        "estimatedchargeremaining",
9034        // crashes / event log (broader)
9035        "get-winevent",
9036        "eventid",
9037        "bugcheck",
9038        "kernelpower",
9039        "win32_ntlogevent",
9040        "filterhashtable",
9041        // scheduled tasks
9042        "get-scheduledtask",
9043        "get-scheduledtaskinfo",
9044        "schtasks",
9045        "taskscheduler",
9046        "get-acl",
9047        "icacls",
9048        "takeown",
9049        "event id 4624",
9050        "eventid 4624",
9051        "who logged in",
9052        "logon history",
9053        "login history",
9054        "get-smbshare",
9055        "net share",
9056        "mbps",
9057        "throughput",
9058        "whoami",
9059        // general cim/wmi diagnostic queries — always use inspect_host
9060        "get-ciminstance win32",
9061        "get-wmiobject win32",
9062        // network admin — always use inspect_host
9063        "arp -",
9064        "arp -a",
9065        "tracert ",
9066        "traceroute ",
9067        "tracepath ",
9068        "get-dnsclientcache",
9069        "ipconfig /displaydns",
9070        "get-netroute",
9071        "get-netneighbor",
9072        "net view",
9073        "get-smbconnection",
9074        "get-smbmapping",
9075        "get-psdrive",
9076        "fdrespub",
9077        "fdphost",
9078        "ssdpsrv",
9079        "upnphost",
9080        "avahi-browse",
9081        "route print",
9082        "ip neigh",
9083        // audio / bluetooth — always use inspect_host
9084        "get-pnpdevice -class audioendpoint",
9085        "get-pnpdevice -class media",
9086        "win32_sounddevice",
9087        "audiosrv",
9088        "audioendpointbuilder",
9089        "windows audio",
9090        "get-pnpdevice -class bluetooth",
9091        "bthserv",
9092        "bthavctpsvc",
9093        "btagservice",
9094        "bluetoothuserservice",
9095        "msiserver",
9096        "appxsvc",
9097        "clipsvc",
9098        "installservice",
9099        "desktopappinstaller",
9100        "microsoft.windowsstore",
9101        "get-appxpackage microsoft.desktopappinstaller",
9102        "get-appxpackage microsoft.windowsstore",
9103        "winget source",
9104        "winget --info",
9105        "onedrive",
9106        "onedrive.exe",
9107        "files on-demand",
9108        "known folder backup",
9109        "disablefilesyncngsc",
9110        "kfmsilentoptin",
9111        "kfmblockoptin",
9112        "get-process chrome",
9113        "get-process msedge",
9114        "get-process firefox",
9115        "get-process msedgewebview2",
9116        "google chrome",
9117        "microsoft edge",
9118        "mozilla firefox",
9119        "webview2",
9120        "msedgewebview2",
9121        "startmenuinternet",
9122        "urlassociations\\http\\userchoice",
9123        "urlassociations\\https\\userchoice",
9124        "software\\policies\\microsoft\\edge",
9125        "software\\policies\\google\\chrome",
9126        "get-winevent",
9127        "event id",
9128        "eventlog",
9129        "event viewer",
9130        "wevtutil",
9131        "cmdkey",
9132        "credential manager",
9133        "get-tpm",
9134        "confirm-securebootuefi",
9135        "win32_tpm",
9136        "dsregcmd",
9137        "webauthmanager",
9138        "web account manager",
9139        "tokenbroker",
9140        "token broker",
9141        "aad broker",
9142        "brokerplugin",
9143        "microsoft.aad.brokerplugin",
9144        "workplace join",
9145        "device registration",
9146        "secure boot",
9147        // active directory - always use inspect_host
9148        "get-aduser",
9149        "get-addomain",
9150        "get-adforest",
9151        "get-adgroup",
9152        "get-adcomputer",
9153        "activedirectory",
9154        "get-localuser",
9155        "get-localgroup",
9156        "get-localgroupmember",
9157        "net user",
9158        "net localgroup",
9159        "netsh winhttp show proxy",
9160        "get-itemproperty.*proxy",
9161        "get-netadapter",
9162        "netsh wlan show",
9163        "test-netconnection",
9164        "resolve-dnsname",
9165        "nslookup",
9166        "dig ",
9167        "gethostentry",
9168        "gethostaddresses",
9169        "getipaddresses",
9170        "[system.net.dns]",
9171        "net.dns]",
9172        "get-netfirewallrule",
9173        // docker / wsl / ssh — always use inspect_host
9174        "docker ps",
9175        "docker info",
9176        "docker images",
9177        "docker container",
9178        "docker inspect",
9179        "docker volume",
9180        "docker system df",
9181        "docker compose ls",
9182        "wsl --list",
9183        "wsl -l",
9184        "wsl --status",
9185        "wsl --version",
9186        "wsl -d",
9187        "wsl df",
9188        "wsl du",
9189        "/mnt/c",
9190        "ssh -v",
9191        "get-service sshd",
9192        "get-service -name sshd",
9193        "cat ~/.ssh",
9194        "ls ~/.ssh",
9195        "ls -la ~/.ssh",
9196        // env / hosts / git config
9197        "get-childitem env:",
9198        "dir env:",
9199        "printenv",
9200        "[environment]::getenvironmentvariable",
9201        "get-content.*hosts",
9202        "cat /etc/hosts",
9203        "type c:\\windows\\system32\\drivers\\etc\\hosts",
9204        "git config --global --list",
9205        "git config --list",
9206        "git config --global",
9207        // database services
9208        "get-service mysql",
9209        "get-service postgresql",
9210        "get-service mongodb",
9211        "get-service redis",
9212        "get-service mssql",
9213        "get-service mariadb",
9214        "systemctl status postgresql",
9215        "systemctl status mysql",
9216        "systemctl status mongod",
9217        "systemctl status redis",
9218        // installed software
9219        "winget list",
9220        "get-package",
9221        "get-itempropert.*uninstall",
9222        "dpkg --get-selections",
9223        "rpm -qa",
9224        "brew list",
9225        // user accounts
9226        "get-localuser",
9227        "get-localgroupmember",
9228        "net user",
9229        "query user",
9230        "net localgroup administrators",
9231        // audit policy
9232        "auditpol /get",
9233        "auditpol",
9234        // shares
9235        "get-smbshare",
9236        "get-smbserverconfiguration",
9237        "net share",
9238        "net use",
9239        // dns servers
9240        "get-dnsclientserveraddress",
9241        "get-dnsclientdohserveraddress",
9242        "get-dnsclientglobalsetting",
9243    ]
9244    .iter()
9245    .any(|needle| lower.contains(needle))
9246        || lower.starts_with("host ")
9247}
9248
9249// Moved strip_think_blocks to inference.rs
9250
9251fn cap_output(text: &str, max_bytes: usize) -> String {
9252    cap_output_for_tool(text, max_bytes, "output")
9253}
9254
9255/// Cap tool output at `max_bytes`. When the output exceeds the cap, write the
9256/// full content to `.hematite/scratch/<tool_name>_<timestamp>.txt` and include
9257/// the path in the truncation notice so the model can read the rest with
9258/// `read_file` instead of losing it entirely.
9259fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
9260    if text.len() <= max_bytes {
9261        return text.to_string();
9262    }
9263
9264    // Write full output to scratch so the model can access it.
9265    let scratch_path = write_output_to_scratch(text, tool_name);
9266
9267    let mut split_at = max_bytes;
9268    while !text.is_char_boundary(split_at) && split_at > 0 {
9269        split_at -= 1;
9270    }
9271
9272    let tail = match &scratch_path {
9273        Some(p) => format!(
9274            "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
9275            text.len(),
9276            text.lines().count(),
9277            p
9278        ),
9279        None => format!("\n... [output capped at {}B]", max_bytes),
9280    };
9281
9282    format!("{}{}", &text[..split_at], tail)
9283}
9284
9285/// Write text to `.hematite/scratch/<tool>_<timestamp>.txt`.
9286/// Returns the relative path on success, None if the write fails.
9287fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
9288    let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
9289    if std::fs::create_dir_all(&scratch_dir).is_err() {
9290        return None;
9291    }
9292    let ts = std::time::SystemTime::now()
9293        .duration_since(std::time::UNIX_EPOCH)
9294        .map(|d| d.as_secs())
9295        .unwrap_or(0);
9296    // Sanitize tool name for use in filename
9297    let safe_name: String = tool_name
9298        .chars()
9299        .map(|c| {
9300            if c.is_alphanumeric() || c == '_' {
9301                c
9302            } else {
9303                '_'
9304            }
9305        })
9306        .collect();
9307    let filename = format!("{}_{}.txt", safe_name, ts);
9308    let abs_path = scratch_dir.join(&filename);
9309    if std::fs::write(&abs_path, text).is_err() {
9310        return None;
9311    }
9312    Some(format!(".hematite/scratch/{}", filename))
9313}
9314
9315#[derive(Default)]
9316struct PromptBudgetStats {
9317    summarized_tool_results: usize,
9318    collapsed_tool_results: usize,
9319    trimmed_chat_messages: usize,
9320    dropped_messages: usize,
9321}
9322
9323fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
9324    crate::agent::inference::estimate_message_batch_tokens(messages)
9325}
9326
9327fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
9328    let budget = compaction::SummaryCompressionBudget {
9329        max_chars,
9330        max_lines: 3,
9331        max_line_chars: max_chars.clamp(80, 240),
9332    };
9333    let compressed = compaction::compress_summary(text, budget).summary;
9334    if compressed.is_empty() {
9335        String::new()
9336    } else {
9337        compressed
9338    }
9339}
9340
9341fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
9342    let tool_name = message.name.as_deref().unwrap_or("tool");
9343    let body = summarize_prompt_blob(message.content.as_str(), 320);
9344    format!(
9345        "[Prompt-budget summary of prior `{}` result]\n{}",
9346        tool_name, body
9347    )
9348}
9349
9350fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
9351    let role = message.role.as_str();
9352    let body = summarize_prompt_blob(message.content.as_str(), 240);
9353    format!(
9354        "[Prompt-budget summary of earlier {} message]\n{}",
9355        role, body
9356    )
9357}
9358
9359fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
9360    if messages.len() > 1 && messages[1].role != "user" {
9361        messages.insert(1, ChatMessage::user("Continuing previous context..."));
9362    }
9363}
9364
9365fn enforce_prompt_budget(
9366    prompt_msgs: &mut Vec<ChatMessage>,
9367    context_length: usize,
9368) -> Option<String> {
9369    let target_tokens = ((context_length as f64) * 0.68) as usize;
9370    if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9371        return None;
9372    }
9373
9374    let mut stats = PromptBudgetStats::default();
9375
9376    // 1. Summarize the newest large tool outputs first.
9377    let mut tool_indices: Vec<usize> = {
9378        let mut v = Vec::with_capacity(prompt_msgs.len());
9379        v.extend(
9380            prompt_msgs
9381                .iter()
9382                .enumerate()
9383                .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx)),
9384        );
9385        v
9386    };
9387    for idx in tool_indices.iter().rev().copied() {
9388        if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9389            break;
9390        }
9391        let original = prompt_msgs[idx].content.as_str().to_string();
9392        if original.len() > 1200 {
9393            prompt_msgs[idx].content =
9394                MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
9395            stats.summarized_tool_results += 1;
9396        }
9397    }
9398
9399    // 2. Collapse older tool results aggressively, keeping only the most recent two verbatim/summarized.
9400    tool_indices.clear();
9401    tool_indices.extend(
9402        prompt_msgs
9403            .iter()
9404            .enumerate()
9405            .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx)),
9406    );
9407    if tool_indices.len() > 2 {
9408        for idx in tool_indices
9409            .iter()
9410            .take(tool_indices.len().saturating_sub(2))
9411            .copied()
9412        {
9413            if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9414                break;
9415            }
9416            prompt_msgs[idx].content = MessageContent::Text(
9417                "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
9418            );
9419            stats.collapsed_tool_results += 1;
9420        }
9421    }
9422
9423    // 3. Trim older long chat messages, but preserve the final user request.
9424    let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9425    for idx in 1..prompt_msgs.len() {
9426        if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9427            break;
9428        }
9429        if Some(idx) == last_user_idx {
9430            continue;
9431        }
9432        let role = prompt_msgs[idx].role.as_str();
9433        if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
9434            prompt_msgs[idx].content =
9435                MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
9436            stats.trimmed_chat_messages += 1;
9437        }
9438    }
9439
9440    // 4. Middle-Out Condensation: Drop oldest tool and assistant messages first, preserving ALL user instructions.
9441    let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9442    let mut idx = 1usize;
9443    while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9444        if idx >= prompt_msgs.len() {
9445            break;
9446        }
9447
9448        let role = prompt_msgs[idx].role.as_str();
9449        if role == "user" || Some(idx) == preserve_last_user_idx {
9450            // NEVER drop user requests if possible, let them stand as immutable context.
9451            idx += 1;
9452            continue;
9453        }
9454
9455        // It's a tool or assistant message from the middle. Drop it.
9456        prompt_msgs.remove(idx);
9457        stats.dropped_messages += 1;
9458    }
9459
9460    // 5. If STILL over budget (e.g. user pasted a giant file in the prompt), drop oldest user messages except the latest.
9461    let mut idx = 1usize;
9462    while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9463        if Some(idx) == preserve_last_user_idx {
9464            idx += 1;
9465            if idx >= prompt_msgs.len() {
9466                break;
9467            }
9468            continue;
9469        }
9470        if idx >= prompt_msgs.len() {
9471            break;
9472        }
9473        prompt_msgs.remove(idx);
9474        stats.dropped_messages += 1;
9475    }
9476
9477    normalize_prompt_start(prompt_msgs);
9478
9479    let new_tokens = estimate_prompt_tokens(prompt_msgs);
9480    if stats.summarized_tool_results == 0
9481        && stats.collapsed_tool_results == 0
9482        && stats.trimmed_chat_messages == 0
9483        && stats.dropped_messages == 0
9484    {
9485        return None;
9486    }
9487
9488    Some(format!(
9489        "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).",
9490        new_tokens,
9491        target_tokens,
9492        stats.summarized_tool_results,
9493        stats.collapsed_tool_results,
9494        stats.trimmed_chat_messages,
9495        stats.dropped_messages
9496    ))
9497}
9498
9499/// Split text into chunks of roughly `words_per_chunk` whitespace-separated tokens.
9500/// Returns true for short, direct tool-use requests that don't benefit from deep reasoning.
9501/// Used to skip the auto-/think prepend so the model calls the tool immediately
9502/// instead of spending thousands of tokens deliberating over a trivial task.
9503fn is_quick_tool_request(input: &str) -> bool {
9504    let lower = input.to_lowercase();
9505    // Explicit run_code requests — sandbox calls need no reasoning warmup.
9506    if lower.contains("run_code") || lower.contains("run code") {
9507        return true;
9508    }
9509    // Short compute/test requests — "calculate X", "test this", "execute Y"
9510    let is_short = input.len() < 120;
9511    let compute_keywords = [
9512        "calculate",
9513        "compute",
9514        "execute",
9515        "run this",
9516        "test this",
9517        "what is ",
9518        "how much",
9519        "how many",
9520        "convert ",
9521        "print ",
9522    ];
9523    if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
9524        return true;
9525    }
9526    false
9527}
9528
9529fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
9530    let avg_word = 6usize;
9531    let mut chunks = Vec::with_capacity(text.len() / (words_per_chunk * avg_word).max(1) + 1);
9532    let mut current = String::with_capacity(words_per_chunk * avg_word);
9533    let mut count = 0;
9534
9535    for ch in text.chars() {
9536        current.push(ch);
9537        if ch == ' ' || ch == '\n' {
9538            count += 1;
9539            if count >= words_per_chunk {
9540                chunks.push(std::mem::take(&mut current));
9541                current = String::with_capacity(words_per_chunk * avg_word);
9542                count = 0;
9543            }
9544        }
9545    }
9546    if !current.is_empty() {
9547        chunks.push(current);
9548    }
9549    chunks
9550}
9551
9552fn repaired_plan_tool_args(
9553    tool_name: &str,
9554    args: &Value,
9555    task_file_exists: bool,
9556    fallback_target: Option<&str>,
9557    explicit_query: Option<&str>,
9558) -> Option<(Value, String)> {
9559    match tool_name {
9560        "read_file" | "inspect_lines" => {
9561            let has_path = args
9562                .as_object()
9563                .and_then(|map| map.get("path"))
9564                .and_then(|v| v.as_str())
9565                .map(|s| !s.trim().is_empty())
9566                .unwrap_or(false);
9567            if has_path {
9568                return None;
9569            }
9570
9571            let target = if task_file_exists {
9572                Some(".hematite/TASK.md")
9573            } else {
9574                fallback_target
9575            }?;
9576            let mut repaired = if args.is_object() {
9577                args.clone()
9578            } else {
9579                Value::Object(serde_json::Map::new())
9580            };
9581            let map = repaired.as_object_mut()?;
9582            map.insert("path".to_string(), Value::String(target.to_string()));
9583            Some((
9584                repaired,
9585                format!(
9586                    "Recovered malformed `{}` call during current-plan execution by grounding it to `{}`.",
9587                    tool_name, target
9588                ),
9589            ))
9590        }
9591        "research_web" => {
9592            let has_query = args
9593                .as_object()
9594                .and_then(|map| map.get("query"))
9595                .and_then(|v| v.as_str())
9596                .map(|s| !s.trim().is_empty())
9597                .unwrap_or(false);
9598            if has_query {
9599                return None;
9600            }
9601            let query = explicit_query?.trim();
9602            if query.is_empty() {
9603                return None;
9604            }
9605            let mut repaired = if args.is_object() {
9606                args.clone()
9607            } else {
9608                Value::Object(serde_json::Map::new())
9609            };
9610            let map = repaired.as_object_mut()?;
9611            map.insert("query".to_string(), Value::String(query.to_string()));
9612            Some((
9613                repaired,
9614                format!(
9615                    "Recovered malformed `research_web` call during current-plan execution by restoring query `{}`.",
9616                    query
9617                ),
9618            ))
9619        }
9620        _ => None,
9621    }
9622}
9623
9624fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
9625    if call.name != "read_file" {
9626        return None;
9627    }
9628    let mut args = call.arguments.clone();
9629    crate::agent::inference::normalize_tool_argument_value(&call.name, &mut args);
9630    let path = args.get("path").and_then(|v| v.as_str())?;
9631    Some(normalize_workspace_path(path))
9632}
9633
9634fn order_batch_reads_first(
9635    calls: Vec<crate::agent::inference::ToolCallResponse>,
9636) -> (
9637    Vec<crate::agent::inference::ToolCallResponse>,
9638    Option<String>,
9639) {
9640    let has_reads = calls.iter().any(|c| {
9641        matches!(
9642            c.function.name.as_str(),
9643            "read_file" | "inspect_lines" | "grep_files" | "list_files"
9644        )
9645    });
9646    let has_edits = calls.iter().any(|c| {
9647        matches!(
9648            c.function.name.as_str(),
9649            "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9650        )
9651    });
9652    if has_reads && has_edits {
9653        let reads: Vec<_> = calls
9654            .into_iter()
9655            .filter(|c| {
9656                !matches!(
9657                    c.function.name.as_str(),
9658                    "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9659                )
9660            })
9661            .collect();
9662        let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
9663        (reads, note)
9664    } else {
9665        (calls, None)
9666    }
9667}
9668
9669fn grep_output_is_high_fanout(output: &str) -> bool {
9670    let Some(summary) = output.lines().next() else {
9671        return false;
9672    };
9673    let hunk_count = summary
9674        .split(", ")
9675        .find_map(|part| {
9676            part.strip_suffix(" hunk(s)")
9677                .and_then(|value| value.parse::<usize>().ok())
9678        })
9679        .unwrap_or(0);
9680    let match_count = summary
9681        .split(' ')
9682        .next()
9683        .and_then(|value| value.parse::<usize>().ok())
9684        .unwrap_or(0);
9685    hunk_count >= 8 || match_count >= 12
9686}
9687
9688fn build_system_with_corrections(
9689    base: &str,
9690    hints: &[String],
9691    gpu: &Arc<GpuState>,
9692    git: &Arc<crate::agent::git_monitor::GitState>,
9693    config: &crate::agent::config::HematiteConfig,
9694) -> String {
9695    let mut system_msg = base.to_string();
9696
9697    // Inject Permission Mode.
9698    system_msg.push_str("\n\n# Permission Mode\n");
9699    let mode_label = match config.mode {
9700        crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
9701        crate::agent::config::PermissionMode::Developer => "DEVELOPER",
9702        crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
9703    };
9704    let _ = writeln!(system_msg, "CURRENT MODE: {}", mode_label);
9705
9706    if config.mode == crate::agent::config::PermissionMode::ReadOnly {
9707        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");
9708    } else {
9709        system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
9710    }
9711
9712    // Inject live hardware status.
9713    let (used, total) = gpu.read();
9714    if total > 0 {
9715        system_msg.push_str("\n\n# Terminal Hardware Context\n");
9716        let _ = writeln!(
9717            system_msg,
9718            "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)",
9719            gpu.gpu_name(),
9720            used as f64 / 1024.0,
9721            total as f64 / 1024.0,
9722            gpu.ratio() * 100.0
9723        );
9724        system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
9725    }
9726
9727    // Inject Git Repository context.
9728    system_msg.push_str("\n\n# Git Repository Context\n");
9729    let git_status_label = git.label();
9730    let git_url = git.url();
9731    let _ = writeln!(
9732        system_msg,
9733        "REMOTE STATUS: {} | URL: {}",
9734        git_status_label, git_url
9735    );
9736
9737    // Live Snapshots (Status/Diff)
9738    let root = crate::tools::file_ops::workspace_root();
9739    if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
9740        system_msg.push_str("\nGit status snapshot:\n");
9741        system_msg.push_str(&status_snapshot);
9742        system_msg.push('\n');
9743    }
9744
9745    if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
9746        system_msg.push_str("\nGit diff snapshot:\n");
9747        system_msg.push_str(&diff_snapshot);
9748        system_msg.push('\n');
9749    }
9750
9751    if git_status_label == "NONE" {
9752        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");
9753    } else if git_status_label == "BEHIND" {
9754        system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
9755    }
9756
9757    // NOTE: Instruction files (CLAUDE.md, HEMATITE.md, etc.) are already injected
9758    // by InferenceEngine::build_system_prompt() via load_instruction_files().
9759    // Injecting them again here would double the token cost (~4K wasted per turn).
9760
9761    if hints.is_empty() {
9762        return system_msg;
9763    }
9764    system_msg.push_str("\n\n# Formatting Corrections\n");
9765    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");
9766    for hint in hints {
9767        let _ = writeln!(system_msg, "- {}", hint);
9768    }
9769    system_msg
9770}
9771
9772fn route_model<'a>(
9773    user_input: &str,
9774    fast_model: Option<&'a str>,
9775    think_model: Option<&'a str>,
9776) -> Option<&'a str> {
9777    let text = user_input.to_lowercase();
9778    let is_think = text.contains("refactor")
9779        || text.contains("rewrite")
9780        || text.contains("implement")
9781        || text.contains("create")
9782        || text.contains("fix")
9783        || text.contains("debug");
9784    let is_fast = text.contains("what")
9785        || text.contains("show")
9786        || text.contains("find")
9787        || text.contains("list")
9788        || text.contains("status");
9789
9790    if is_think && think_model.is_some() {
9791        return think_model;
9792    } else if is_fast && fast_model.is_some() {
9793        return fast_model;
9794    }
9795    None
9796}
9797
9798fn is_parallel_safe(name: &str) -> bool {
9799    let metadata = crate::agent::inference::tool_metadata_for_name(name);
9800    !metadata.mutates_workspace && !metadata.external_surface
9801}
9802
9803fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
9804    if docs_only_mode {
9805        return true;
9806    }
9807
9808    let lower = query.to_ascii_lowercase();
9809    [
9810        "what did we decide",
9811        "why did we decide",
9812        "what did we say",
9813        "what did we do",
9814        "earlier today",
9815        "yesterday",
9816        "last week",
9817        "last month",
9818        "earlier",
9819        "remember",
9820        "session",
9821        "import",
9822    ]
9823    .iter()
9824    .any(|needle| lower.contains(needle))
9825        || lower
9826            .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
9827            .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
9828}
9829
9830#[cfg(test)]
9831mod tests {
9832    use super::*;
9833
9834    #[test]
9835    fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
9836        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."}"#;
9837        let class = classify_runtime_failure(detail);
9838        assert_eq!(class, RuntimeFailureClass::ContextWindow);
9839        assert_eq!(class.tag(), "context_window");
9840        assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
9841    }
9842
9843    #[test]
9844    fn formatted_runtime_failure_is_not_wrapped_twice() {
9845        let detail =
9846            "[failure:provider_degraded] Retry once automatically, then narrow the turn or restart LM Studio if it persists. Detail: LMS unreachable: Request failed";
9847        let formatted = format_runtime_failure(RuntimeFailureClass::ProviderDegraded, detail);
9848        assert_eq!(formatted, detail);
9849        assert_eq!(formatted.matches("[failure:provider_degraded]").count(), 1);
9850    }
9851
9852    #[test]
9853    fn explicit_search_detection_requires_search_language() {
9854        assert!(is_explicit_web_search_request("search for ocean bennett"));
9855        assert!(is_explicit_web_search_request("google ocean bennett"));
9856        assert!(is_explicit_web_search_request("look up ocean bennett"));
9857        assert!(!is_explicit_web_search_request("who is ocean bennett"));
9858    }
9859
9860    #[test]
9861    fn explicit_search_query_extracts_leading_search_clause_from_mixed_request() {
9862        assert_eq!(
9863            extract_explicit_web_search_query(
9864                "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it"
9865            ),
9866            Some("uefn toolbelt".to_string())
9867        );
9868    }
9869
9870    #[test]
9871    fn auto_research_handover_is_turn_scoped_only() {
9872        assert!(should_use_turn_scoped_investigation_mode(
9873            WorkflowMode::Auto,
9874            QueryIntentClass::Research
9875        ));
9876        assert!(!should_use_turn_scoped_investigation_mode(
9877            WorkflowMode::Ask,
9878            QueryIntentClass::Research
9879        ));
9880        assert!(!should_use_turn_scoped_investigation_mode(
9881            WorkflowMode::Auto,
9882            QueryIntentClass::RepoArchitecture
9883        ));
9884    }
9885
9886    #[test]
9887    fn research_provider_fallback_mentions_direct_search_results() {
9888        let fallback = build_research_provider_fallback(
9889            "[Source: SearXNG]\n\n### 1. [Ocean Bennett](https://example.com)\nBio",
9890        );
9891        assert!(fallback.contains("Local web search succeeded"));
9892        assert!(fallback.contains("[Source: SearXNG]"));
9893        assert!(fallback.contains("Ocean Bennett"));
9894    }
9895
9896    #[test]
9897    fn runtime_failure_maps_to_provider_and_checkpoint_state() {
9898        assert_eq!(
9899            provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9900            Some(ProviderRuntimeState::ContextWindow)
9901        );
9902        assert_eq!(
9903            checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9904            Some(OperatorCheckpointState::BlockedContextWindow)
9905        );
9906        assert_eq!(
9907            provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9908            Some(ProviderRuntimeState::Degraded)
9909        );
9910        assert_eq!(
9911            checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9912            None
9913        );
9914    }
9915
9916    #[test]
9917    fn intent_router_treats_tool_registry_ownership_as_product_truth() {
9918        let intent = classify_query_intent(
9919            WorkflowMode::ReadOnly,
9920            "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
9921        );
9922        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9923        assert_eq!(
9924            intent.direct_answer,
9925            Some(DirectAnswerKind::ToolRegistryOwnership)
9926        );
9927    }
9928
9929    #[test]
9930    fn intent_router_treats_tool_classes_as_product_truth() {
9931        let intent = classify_query_intent(
9932            WorkflowMode::ReadOnly,
9933            "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.",
9934        );
9935        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9936        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
9937    }
9938
9939    #[test]
9940    fn tool_registry_ownership_answer_mentions_new_owner_file() {
9941        let answer = build_tool_registry_ownership_answer();
9942        assert!(answer.contains("src/agent/tool_registry.rs"));
9943        assert!(answer.contains("builtin dispatch path"));
9944        assert!(answer.contains("src/agent/conversation.rs"));
9945    }
9946
9947    #[test]
9948    fn intent_router_treats_mcp_lifecycle_as_product_truth() {
9949        let intent = classify_query_intent(
9950            WorkflowMode::ReadOnly,
9951            "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
9952        );
9953        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9954        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
9955    }
9956
9957    #[test]
9958    fn intent_router_short_circuits_unsafe_commit_pressure() {
9959        let intent = classify_query_intent(
9960            WorkflowMode::Auto,
9961            "Make a code change, skip verification, and commit it immediately.",
9962        );
9963        assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9964        assert_eq!(
9965            intent.direct_answer,
9966            Some(DirectAnswerKind::UnsafeWorkflowPressure)
9967        );
9968    }
9969
9970    #[test]
9971    fn unsafe_workflow_pressure_answer_requires_verification() {
9972        let answer = build_unsafe_workflow_pressure_answer();
9973        assert!(answer.contains("should not skip verification"));
9974        assert!(answer.contains("run the appropriate verification path"));
9975        assert!(answer.contains("only then commit"));
9976    }
9977
9978    #[test]
9979    fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
9980        let intent = classify_query_intent(
9981            WorkflowMode::ReadOnly,
9982            "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.",
9983        );
9984        assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
9985        assert!(intent.architecture_overview_mode);
9986        assert_eq!(intent.direct_answer, None);
9987    }
9988
9989    #[test]
9990    fn intent_router_marks_host_inspection_questions() {
9991        let intent = classify_query_intent(
9992            WorkflowMode::Auto,
9993            "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.",
9994        );
9995        assert!(intent.host_inspection_mode);
9996        assert_eq!(
9997            preferred_host_inspection_topic(
9998                "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."
9999            ),
10000            Some("summary")
10001        );
10002    }
10003
10004    #[test]
10005    fn intent_router_treats_purpose_question_as_local_identity() {
10006        let intent = classify_query_intent(WorkflowMode::Auto, "What is your purpose?");
10007        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::Identity));
10008    }
10009
10010    #[test]
10011    fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
10012        assert!(should_use_vein_in_chat(
10013            "What did we decide on 2026-04-09 about docs-only mode?",
10014            false
10015        ));
10016        assert!(should_use_vein_in_chat("Summarize these local notes", true));
10017        assert!(!should_use_vein_in_chat("Tell me a joke", false));
10018    }
10019
10020    #[test]
10021    fn shell_host_inspection_guard_matches_path_and_version_commands() {
10022        assert!(shell_looks_like_structured_host_inspection(
10023            "$env:PATH -split ';'"
10024        ));
10025        assert!(shell_looks_like_structured_host_inspection(
10026            "cargo --version"
10027        ));
10028        assert!(shell_looks_like_structured_host_inspection(
10029            "Get-NetTCPConnection -LocalPort 3000"
10030        ));
10031        assert!(shell_looks_like_structured_host_inspection(
10032            "netstat -ano | findstr :3000"
10033        ));
10034        assert!(shell_looks_like_structured_host_inspection(
10035            "Get-Process | Sort-Object WS -Descending"
10036        ));
10037        assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
10038        assert!(shell_looks_like_structured_host_inspection("Get-Service"));
10039        assert!(shell_looks_like_structured_host_inspection(
10040            "winget --version"
10041        ));
10042        assert!(shell_looks_like_structured_host_inspection(
10043            "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
10044        ));
10045        assert!(shell_looks_like_structured_host_inspection(
10046            "Get-NetNeighbor -AddressFamily IPv4"
10047        ));
10048        assert!(shell_looks_like_structured_host_inspection(
10049            "Get-SmbConnection"
10050        ));
10051        assert!(shell_looks_like_structured_host_inspection(
10052            "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
10053        ));
10054        assert!(shell_looks_like_structured_host_inspection(
10055            "Get-PnpDevice -Class AudioEndpoint"
10056        ));
10057        assert!(shell_looks_like_structured_host_inspection(
10058            "Get-CimInstance Win32_SoundDevice"
10059        ));
10060        assert!(shell_looks_like_structured_host_inspection(
10061            "Get-PnpDevice -Class Bluetooth"
10062        ));
10063        assert!(shell_looks_like_structured_host_inspection(
10064            "Get-Service bthserv,BthAvctpSvc,BTAGService"
10065        ));
10066        assert!(shell_looks_like_structured_host_inspection(
10067            "Get-Service msiserver,AppXSvc,ClipSVC,InstallService"
10068        ));
10069        assert!(shell_looks_like_structured_host_inspection(
10070            "Get-AppxPackage Microsoft.DesktopAppInstaller"
10071        ));
10072        assert!(shell_looks_like_structured_host_inspection(
10073            "winget source list"
10074        ));
10075        assert!(shell_looks_like_structured_host_inspection(
10076            "Get-Process OneDrive"
10077        ));
10078        assert!(shell_looks_like_structured_host_inspection(
10079            "Get-ItemProperty HKCU:\\Software\\Microsoft\\OneDrive\\Accounts"
10080        ));
10081        assert!(shell_looks_like_structured_host_inspection("cmdkey /list"));
10082        assert!(shell_looks_like_structured_host_inspection("Get-Tpm"));
10083        assert!(shell_looks_like_structured_host_inspection(
10084            "Confirm-SecureBootUEFI"
10085        ));
10086        assert!(shell_looks_like_structured_host_inspection(
10087            "dsregcmd /status"
10088        ));
10089        assert!(shell_looks_like_structured_host_inspection(
10090            "Get-Service TokenBroker,wlidsvc,OneAuth"
10091        ));
10092        assert!(shell_looks_like_structured_host_inspection(
10093            "Get-AppxPackage Microsoft.AAD.BrokerPlugin"
10094        ));
10095        assert!(shell_looks_like_structured_host_inspection(
10096            "host github.com"
10097        ));
10098        assert!(shell_looks_like_structured_host_inspection(
10099            "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
10100        ));
10101    }
10102
10103    #[test]
10104    fn dns_shell_target_extraction_handles_common_lookup_forms() {
10105        assert_eq!(
10106            extract_dns_lookup_target_from_shell("host github.com").as_deref(),
10107            Some("github.com")
10108        );
10109        assert_eq!(
10110            extract_dns_lookup_target_from_shell(
10111                "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
10112            )
10113            .as_deref(),
10114            Some("github.com")
10115        );
10116        assert_eq!(
10117            extract_dns_lookup_target_from_shell(
10118                "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
10119            )
10120            .as_deref(),
10121            Some("github.com")
10122        );
10123    }
10124
10125    #[test]
10126    fn dns_prompt_target_extraction_handles_plain_english_questions() {
10127        assert_eq!(
10128            extract_dns_lookup_target_from_text("Show me the A record for github.com").as_deref(),
10129            Some("github.com")
10130        );
10131        assert_eq!(
10132            extract_dns_lookup_target_from_text("What is the IP address of google.com").as_deref(),
10133            Some("google.com")
10134        );
10135    }
10136
10137    #[test]
10138    fn dns_record_type_extraction_handles_prompt_and_shell_forms() {
10139        assert_eq!(
10140            extract_dns_record_type_from_text("Show me the A record for github.com"),
10141            Some("A")
10142        );
10143        assert_eq!(
10144            extract_dns_record_type_from_text("What is the IP address of google.com"),
10145            Some("A")
10146        );
10147        assert_eq!(
10148            extract_dns_record_type_from_text("Resolve the MX record for example.com"),
10149            Some("MX")
10150        );
10151        assert_eq!(
10152            extract_dns_record_type_from_shell(
10153                "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
10154            ),
10155            Some("A")
10156        );
10157        assert_eq!(
10158            extract_dns_record_type_from_shell("nslookup -type=mx example.com"),
10159            Some("MX")
10160        );
10161    }
10162
10163    #[test]
10164    fn fill_missing_dns_lookup_name_backfills_from_latest_user_prompt() {
10165        let mut tool_name = "inspect_host".to_string();
10166        let mut args = serde_json::json!({
10167            "topic": "dns_lookup"
10168        });
10169        rewrite_host_tool_call(
10170            &mut tool_name,
10171            &mut args,
10172            Some("Show me the A record for github.com"),
10173        );
10174        assert_eq!(tool_name, "inspect_host");
10175        assert_eq!(
10176            args.get("name").and_then(|value| value.as_str()),
10177            Some("github.com")
10178        );
10179        assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10180    }
10181
10182    #[test]
10183    fn host_inspection_args_from_prompt_populates_dns_lookup_fields() {
10184        let args =
10185            host_inspection_args_from_prompt("dns_lookup", "What is the IP address of google.com");
10186        assert_eq!(
10187            args.get("name").and_then(|value| value.as_str()),
10188            Some("google.com")
10189        );
10190        assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10191    }
10192
10193    #[test]
10194    fn host_inspection_args_from_prompt_populates_event_query_fields() {
10195        let args = host_inspection_args_from_prompt(
10196            "event_query",
10197            "Show me all System errors from the Event Log that occurred in the last 4 hours.",
10198        );
10199        assert_eq!(
10200            args.get("log").and_then(|value| value.as_str()),
10201            Some("System")
10202        );
10203        assert_eq!(
10204            args.get("level").and_then(|value| value.as_str()),
10205            Some("Error")
10206        );
10207        assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10208    }
10209
10210    #[test]
10211    fn fill_missing_event_query_args_backfills_from_latest_user_prompt() {
10212        let mut tool_name = "inspect_host".to_string();
10213        let mut args = serde_json::json!({
10214            "topic": "event_query"
10215        });
10216        rewrite_host_tool_call(
10217            &mut tool_name,
10218            &mut args,
10219            Some("Show me all System errors from the Event Log that occurred in the last 4 hours."),
10220        );
10221        assert_eq!(tool_name, "inspect_host");
10222        assert_eq!(
10223            args.get("log").and_then(|value| value.as_str()),
10224            Some("System")
10225        );
10226        assert_eq!(
10227            args.get("level").and_then(|value| value.as_str()),
10228            Some("Error")
10229        );
10230        assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10231    }
10232
10233    #[test]
10234    fn intent_router_picks_ports_for_listening_port_questions() {
10235        assert_eq!(
10236            preferred_host_inspection_topic(
10237                "Show me what is listening on port 3000 and whether anything unexpected is exposed."
10238            ),
10239            Some("ports")
10240        );
10241    }
10242
10243    #[test]
10244    fn intent_router_picks_processes_for_host_process_questions() {
10245        assert_eq!(
10246            preferred_host_inspection_topic(
10247                "Show me what processes are using the most RAM right now."
10248            ),
10249            Some("processes")
10250        );
10251    }
10252
10253    #[test]
10254    fn intent_router_picks_network_for_adapter_questions() {
10255        assert_eq!(
10256            preferred_host_inspection_topic(
10257                "Show me my active network adapters, IP addresses, gateways, and DNS servers."
10258            ),
10259            Some("network")
10260        );
10261    }
10262
10263    #[test]
10264    fn intent_router_picks_services_for_service_questions() {
10265        assert_eq!(
10266            preferred_host_inspection_topic(
10267                "Show me the running services and startup types that matter for a normal dev machine."
10268            ),
10269            Some("services")
10270        );
10271    }
10272
10273    #[test]
10274    fn intent_router_picks_env_doctor_for_package_manager_questions() {
10275        assert_eq!(
10276            preferred_host_inspection_topic(
10277                "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
10278            ),
10279            Some("env_doctor")
10280        );
10281    }
10282
10283    #[test]
10284    fn intent_router_picks_fix_plan_for_host_remediation_questions() {
10285        assert_eq!(
10286            preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
10287            Some("fix_plan")
10288        );
10289        assert_eq!(
10290            preferred_host_inspection_topic(
10291                "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
10292            ),
10293            Some("fix_plan")
10294        );
10295    }
10296
10297    #[test]
10298    fn intent_router_picks_audio_for_sound_and_microphone_questions() {
10299        assert_eq!(
10300            preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
10301            Some("audio")
10302        );
10303        assert_eq!(
10304            preferred_host_inspection_topic(
10305                "Check my microphone and playback devices because Windows Audio seems broken."
10306            ),
10307            Some("audio")
10308        );
10309    }
10310
10311    #[test]
10312    fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
10313        assert_eq!(
10314            preferred_host_inspection_topic(
10315                "Why won't this Bluetooth headset pair and stay connected?"
10316            ),
10317            Some("bluetooth")
10318        );
10319        assert_eq!(
10320            preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
10321            Some("bluetooth")
10322        );
10323    }
10324
10325    #[test]
10326    fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
10327        let mut args = serde_json::json!({
10328            "topic": "fix_plan"
10329        });
10330
10331        fill_missing_fix_plan_issue(
10332            "inspect_host",
10333            &mut args,
10334            Some("/think\nHow do I fix cargo not found on this machine?"),
10335        );
10336
10337        assert_eq!(
10338            args.get("issue").and_then(|value| value.as_str()),
10339            Some("How do I fix cargo not found on this machine?")
10340        );
10341    }
10342
10343    #[test]
10344    fn shell_fix_question_rewrites_to_fix_plan() {
10345        let args = serde_json::json!({
10346            "command": "where cargo"
10347        });
10348
10349        assert!(should_rewrite_shell_to_fix_plan(
10350            "shell",
10351            &args,
10352            Some("How do I fix cargo not found on this machine?")
10353        ));
10354    }
10355
10356    #[test]
10357    fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
10358        let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
10359        let shell_key = normalized_tool_call_key_for_dedupe(
10360            "shell",
10361            r#"{"command":"where cargo"}"#,
10362            false,
10363            latest_user_prompt,
10364        );
10365        let fix_plan_key = normalized_tool_call_key_for_dedupe(
10366            "inspect_host",
10367            r#"{"topic":"fix_plan"}"#,
10368            false,
10369            latest_user_prompt,
10370        );
10371
10372        assert_eq!(shell_key, fix_plan_key);
10373    }
10374
10375    #[test]
10376    fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
10377        let (tool_name, args) = normalized_tool_call_for_execution(
10378            "shell",
10379            &serde_json::json!({"command":"pwsh ./clean.ps1 -Deep -PruneDist"}),
10380            false,
10381            Some("Run my cleanup scripts."),
10382        );
10383
10384        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10385        assert_eq!(
10386            args.get("workflow").and_then(|value| value.as_str()),
10387            Some("clean")
10388        );
10389        assert_eq!(
10390            args.get("deep").and_then(|value| value.as_bool()),
10391            Some(true)
10392        );
10393        assert_eq!(
10394            args.get("prune_dist").and_then(|value| value.as_bool()),
10395            Some(true)
10396        );
10397    }
10398
10399    #[test]
10400    fn shell_release_script_rewrites_to_maintainer_workflow() {
10401        let (tool_name, args) = normalized_tool_call_for_execution(
10402            "shell",
10403            &serde_json::json!({"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}),
10404            false,
10405            Some("Run the release flow."),
10406        );
10407
10408        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10409        assert_eq!(
10410            args.get("workflow").and_then(|value| value.as_str()),
10411            Some("release")
10412        );
10413        assert_eq!(
10414            args.get("version").and_then(|value| value.as_str()),
10415            Some("0.4.5")
10416        );
10417        assert_eq!(
10418            args.get("push").and_then(|value| value.as_bool()),
10419            Some(true)
10420        );
10421    }
10422
10423    #[test]
10424    fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
10425        let (tool_name, args) = normalized_tool_call_for_execution(
10426            "shell",
10427            &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10428            false,
10429            Some("Run the deep cleanup and prune old dist artifacts."),
10430        );
10431
10432        assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10433        assert_eq!(
10434            args.get("workflow").and_then(|value| value.as_str()),
10435            Some("clean")
10436        );
10437        assert_eq!(
10438            args.get("deep").and_then(|value| value.as_bool()),
10439            Some(true)
10440        );
10441        assert_eq!(
10442            args.get("prune_dist").and_then(|value| value.as_bool()),
10443            Some(true)
10444        );
10445    }
10446
10447    #[test]
10448    fn shell_cargo_test_rewrites_to_workspace_workflow() {
10449        let (tool_name, args) = normalized_tool_call_for_execution(
10450            "shell",
10451            &serde_json::json!({"command":"cargo test"}),
10452            false,
10453            Some("Run cargo test in this project."),
10454        );
10455
10456        assert_eq!(tool_name, "run_workspace_workflow");
10457        assert_eq!(
10458            args.get("workflow").and_then(|value| value.as_str()),
10459            Some("command")
10460        );
10461        assert_eq!(
10462            args.get("command").and_then(|value| value.as_str()),
10463            Some("cargo test")
10464        );
10465    }
10466
10467    #[test]
10468    fn current_plan_execution_request_accepts_saved_plan_command() {
10469        assert!(is_current_plan_execution_request("/implement-plan"));
10470        assert!(is_current_plan_execution_request(
10471            "Implement the current plan."
10472        ));
10473    }
10474
10475    #[test]
10476    fn architect_operator_note_points_to_execute_path() {
10477        let plan = crate::tools::plan::PlanHandoff {
10478            goal: "Tighten startup workflow guidance".into(),
10479            target_files: vec!["src/runtime.rs".into()],
10480            ordered_steps: vec!["Update the startup banner".into()],
10481            verification: "cargo check --tests".into(),
10482            risks: vec![],
10483            open_questions: vec![],
10484        };
10485        let note = architect_handoff_operator_note(&plan);
10486        assert!(note.contains("`.hematite/PLAN.md`"));
10487        assert!(note.contains("/implement-plan"));
10488        assert!(note.contains("/code implement the current plan"));
10489    }
10490
10491    #[test]
10492    fn sovereign_scaffold_handoff_carries_explicit_research_step() {
10493        let mut targets = std::collections::BTreeSet::new();
10494        targets.insert("index.html".to_string());
10495        let plan = build_sovereign_scaffold_handoff(
10496            "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it",
10497            &targets,
10498        );
10499
10500        assert!(plan
10501            .ordered_steps
10502            .iter()
10503            .any(|step| step.contains("research_web")));
10504        assert!(plan
10505            .ordered_steps
10506            .iter()
10507            .any(|step| step.contains("uefn toolbelt")));
10508    }
10509
10510    #[test]
10511    fn single_file_html_sovereign_targets_only_index() {
10512        let targets = default_sovereign_scaffold_targets(
10513            "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",
10514        );
10515
10516        assert!(targets.contains("index.html"));
10517        assert!(!targets.contains("style.css"));
10518        assert!(!targets.contains("script.js"));
10519    }
10520
10521    #[test]
10522    fn single_file_html_handoff_verification_mentions_self_contained_index() {
10523        let mut targets = std::collections::BTreeSet::new();
10524        targets.insert("index.html".to_string());
10525        let plan = build_sovereign_scaffold_handoff(
10526            "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",
10527            &targets,
10528        );
10529
10530        assert!(plan.verification.contains("index.html"));
10531        assert!(plan.verification.contains("self-contained"));
10532        assert!(plan
10533            .ordered_steps
10534            .iter()
10535            .any(|step| step.contains("single `index.html` file")));
10536    }
10537
10538    #[test]
10539    fn plan_handoff_mentions_tool_detects_research_steps() {
10540        let plan = crate::tools::plan::PlanHandoff {
10541            goal: "Build the site".into(),
10542            target_files: vec!["index.html".into()],
10543            ordered_steps: vec!["Use `research_web` first to gather context.".into()],
10544            verification: "verify_build(action: \"build\")".into(),
10545            risks: vec![],
10546            open_questions: vec![],
10547        };
10548
10549        assert!(plan_handoff_mentions_tool(&plan, "research_web"));
10550        assert!(!plan_handoff_mentions_tool(&plan, "fetch_docs"));
10551    }
10552
10553    #[test]
10554    fn parse_task_checklist_progress_counts_checked_items() {
10555        let progress = parse_task_checklist_progress(
10556            r#"
10557- [x] Build the landing page shell
10558- [ ] Wire the responsive nav
10559* [X] Add hero section copy
10560Plain paragraph
10561"#,
10562        );
10563
10564        assert_eq!(progress.total, 3);
10565        assert_eq!(progress.completed, 2);
10566        assert_eq!(progress.remaining, 1);
10567        assert!(progress.has_open_items());
10568    }
10569
10570    #[test]
10571    fn merge_plan_allowed_paths_includes_hematite_sidecars() {
10572        let allowed = merge_plan_allowed_paths(&["src/main.rs".to_string()]);
10573
10574        // Use ends_with instead of contains(&normalize_workspace_path(...)) to avoid a
10575        // race condition: normalize_workspace_path reads current_dir(), which concurrent
10576        // tests that call set_current_dir() can change between the two call sites.
10577        assert!(allowed.iter().any(|p| p.ends_with("/src/main.rs")));
10578        assert!(allowed
10579            .iter()
10580            .any(|path| path.ends_with("/.hematite/task.md")));
10581        assert!(allowed
10582            .iter()
10583            .any(|path| path.ends_with("/.hematite/plan.md")));
10584    }
10585
10586    #[test]
10587    fn repaired_plan_tool_args_recovers_empty_read_to_task_ledger() {
10588        let args = serde_json::json!({});
10589        let (repaired, note) =
10590            repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10591
10592        assert_eq!(
10593            repaired.get("path").and_then(|v| v.as_str()),
10594            Some(".hematite/TASK.md")
10595        );
10596        assert!(note.contains(".hematite/TASK.md"));
10597    }
10598
10599    #[test]
10600    fn repaired_plan_tool_args_recovers_empty_research_query() {
10601        let args = serde_json::json!({});
10602        let (repaired, note) = repaired_plan_tool_args(
10603            "research_web",
10604            &args,
10605            true,
10606            Some("index.html"),
10607            Some("uefn toolbelt"),
10608        )
10609        .unwrap();
10610
10611        assert_eq!(
10612            repaired.get("query").and_then(|v| v.as_str()),
10613            Some("uefn toolbelt")
10614        );
10615        assert!(note.contains("uefn toolbelt"));
10616    }
10617
10618    #[test]
10619    fn repaired_plan_tool_args_recovers_non_object_read_call() {
10620        let args = serde_json::json!("");
10621        let (repaired, _) =
10622            repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10623
10624        assert_eq!(
10625            repaired.get("path").and_then(|v| v.as_str()),
10626            Some(".hematite/TASK.md")
10627        );
10628    }
10629
10630    #[test]
10631    fn force_plan_mutation_prompt_names_target_files() {
10632        let prompt = build_force_plan_mutation_prompt(
10633            TaskChecklistProgress {
10634                total: 5,
10635                completed: 0,
10636                remaining: 5,
10637            },
10638            &["index.html".to_string()],
10639        );
10640
10641        assert!(prompt.contains(".hematite/TASK.md"));
10642        assert!(prompt.contains("`index.html`"));
10643        assert!(prompt.contains("Do not summarize"));
10644    }
10645
10646    #[test]
10647    fn current_plan_scope_recovery_prompt_names_saved_targets() {
10648        let prompt = build_current_plan_scope_recovery_prompt(&["index.html".to_string()]);
10649
10650        assert!(prompt.contains("`index.html`"));
10651        assert!(prompt.contains(".hematite/TASK.md"));
10652        assert!(prompt.contains("Do not branch into unrelated files"));
10653    }
10654
10655    #[test]
10656    fn task_ledger_closeout_prompt_demands_checklist_update() {
10657        let prompt = build_task_ledger_closeout_prompt(
10658            TaskChecklistProgress {
10659                total: 5,
10660                completed: 0,
10661                remaining: 5,
10662            },
10663            &["index.html".to_string()],
10664        );
10665
10666        assert!(prompt.contains(".hematite/TASK.md"));
10667        assert!(prompt.contains("`index.html`"));
10668        assert!(prompt.contains("Do not summarize"));
10669        assert!(prompt.contains("`[x]`"));
10670    }
10671
10672    #[test]
10673    fn suppresses_recoverable_blocked_tool_result_only_when_redirect_exists() {
10674        assert!(should_suppress_recoverable_tool_result(true, true));
10675        assert!(!should_suppress_recoverable_tool_result(true, false));
10676        assert!(!should_suppress_recoverable_tool_result(false, true));
10677    }
10678
10679    #[test]
10680    fn sovereign_closeout_detects_materialized_targets() {
10681        let _cwd_lock = crate::TEST_CWD_LOCK
10682            .lock()
10683            .unwrap_or_else(|e| e.into_inner());
10684        let temp = tempfile::tempdir().unwrap();
10685        let previous = env!("CARGO_MANIFEST_DIR");
10686        std::env::set_current_dir(temp.path()).unwrap();
10687        std::fs::write("index.html", "<html>ok</html>").unwrap();
10688
10689        assert!(target_files_materialized(&["index.html".to_string()]));
10690
10691        std::env::set_current_dir(previous).unwrap();
10692    }
10693
10694    #[test]
10695    fn deterministic_sovereign_closeout_returns_summary_when_targets_exist() {
10696        let _cwd_lock = crate::TEST_CWD_LOCK
10697            .lock()
10698            .unwrap_or_else(|e| e.into_inner());
10699        let temp = tempfile::tempdir().unwrap();
10700        let previous = env!("CARGO_MANIFEST_DIR");
10701        std::env::set_current_dir(temp.path()).unwrap();
10702        std::fs::create_dir_all(".hematite").unwrap();
10703        std::fs::write("index.html", "<html>ok</html>").unwrap();
10704        std::fs::write(".hematite/TASK.md", "# Task Ledger\n\n- [ ] Build index\n").unwrap();
10705        std::fs::write(".hematite/WALKTHROUGH.md", "").unwrap();
10706
10707        let plan = crate::tools::plan::PlanHandoff {
10708            goal: "Continue the sovereign scaffold task in this new project root".to_string(),
10709            target_files: vec!["index.html".to_string()],
10710            ordered_steps: vec!["Build index".to_string()],
10711            verification: "Open index.html".to_string(),
10712            risks: vec![],
10713            open_questions: vec![],
10714        };
10715
10716        let summary = maybe_deterministic_sovereign_closeout(Some(&plan), true).unwrap();
10717        let task = std::fs::read_to_string(".hematite/TASK.md").unwrap();
10718
10719        std::env::set_current_dir(previous).unwrap();
10720
10721        assert!(summary.contains("Sovereign Scaffold Task Complete"));
10722        assert!(task.contains("- [x] Build index"));
10723    }
10724
10725    #[test]
10726    fn continue_plan_execution_requires_progress_and_open_items() {
10727        let mut mutated = std::collections::BTreeSet::new();
10728        mutated.insert("index.html".to_string());
10729
10730        assert!(should_continue_plan_execution(
10731            1,
10732            Some(TaskChecklistProgress {
10733                total: 3,
10734                completed: 1,
10735                remaining: 2,
10736            }),
10737            Some(TaskChecklistProgress {
10738                total: 3,
10739                completed: 2,
10740                remaining: 1,
10741            }),
10742            &mutated,
10743        ));
10744
10745        assert!(!should_continue_plan_execution(
10746            1,
10747            Some(TaskChecklistProgress {
10748                total: 3,
10749                completed: 2,
10750                remaining: 1,
10751            }),
10752            Some(TaskChecklistProgress {
10753                total: 3,
10754                completed: 2,
10755                remaining: 1,
10756            }),
10757            &std::collections::BTreeSet::new(),
10758        ));
10759
10760        assert!(!should_continue_plan_execution(
10761            6,
10762            Some(TaskChecklistProgress {
10763                total: 3,
10764                completed: 2,
10765                remaining: 1,
10766            }),
10767            Some(TaskChecklistProgress {
10768                total: 3,
10769                completed: 3,
10770                remaining: 0,
10771            }),
10772            &mutated,
10773        ));
10774    }
10775
10776    #[test]
10777    fn website_validation_runs_for_website_contract_frontend_paths() {
10778        let contract = crate::agent::workspace_profile::RuntimeContract {
10779            loop_family: "website".to_string(),
10780            app_kind: "website".to_string(),
10781            framework_hint: Some("vite".to_string()),
10782            preferred_workflows: vec!["website_validate".to_string()],
10783            delivery_phases: vec!["design".to_string(), "validate".to_string()],
10784            verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
10785            quality_gates: vec!["critical routes return HTTP 200".to_string()],
10786            local_url_hint: Some("http://127.0.0.1:5173/".to_string()),
10787            route_hints: vec!["/".to_string()],
10788        };
10789        let mutated = std::collections::BTreeSet::from([
10790            "src/pages/index.tsx".to_string(),
10791            "public/app.css".to_string(),
10792        ]);
10793        assert!(should_run_website_validation(Some(&contract), &mutated));
10794    }
10795
10796    #[test]
10797    fn website_validation_skips_non_website_contracts() {
10798        let contract = crate::agent::workspace_profile::RuntimeContract {
10799            loop_family: "service".to_string(),
10800            app_kind: "node-service".to_string(),
10801            framework_hint: Some("express".to_string()),
10802            preferred_workflows: vec!["build".to_string()],
10803            delivery_phases: vec!["define boundary".to_string()],
10804            verification_workflows: vec!["build".to_string()],
10805            quality_gates: vec!["build stays green".to_string()],
10806            local_url_hint: None,
10807            route_hints: Vec::new(),
10808        };
10809        let mutated = std::collections::BTreeSet::from(["server.ts".to_string()]);
10810        assert!(!should_run_website_validation(Some(&contract), &mutated));
10811        assert!(!should_run_website_validation(None, &mutated));
10812    }
10813
10814    #[test]
10815    fn repeat_guard_exempts_structured_website_validation() {
10816        assert!(is_repeat_guard_exempt_tool_call(
10817            "run_workspace_workflow",
10818            &serde_json::json!({ "workflow": "website_validate" }),
10819        ));
10820        assert!(!is_repeat_guard_exempt_tool_call(
10821            "run_workspace_workflow",
10822            &serde_json::json!({ "workflow": "build" }),
10823        ));
10824    }
10825
10826    #[test]
10827    fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
10828        let (tool_name, args) = normalized_tool_call_for_execution(
10829            "shell",
10830            &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10831            false,
10832            Some("Run the tests in this project."),
10833        );
10834
10835        assert_eq!(tool_name, "run_workspace_workflow");
10836        assert_eq!(
10837            args.get("workflow").and_then(|value| value.as_str()),
10838            Some("test")
10839        );
10840    }
10841
10842    #[test]
10843    fn scaffold_prompt_does_not_rewrite_to_workspace_workflow() {
10844        let (tool_name, _args) = normalized_tool_call_for_execution(
10845            "shell",
10846            &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10847            false,
10848            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."),
10849        );
10850
10851        assert_eq!(tool_name, "shell");
10852    }
10853
10854    #[test]
10855    fn failing_path_parser_extracts_cargo_error_locations() {
10856        let output = r#"
10857BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
10858
10859error[E0412]: cannot find type `Foo` in this scope
10860  --> src/agent/conversation.rs:42:12
10861   |
1086242 |     field: Foo,
10863   |            ^^^ not found
10864
10865error[E0308]: mismatched types
10866  --> src/tools/file_ops.rs:100:5
10867   |
10868   = note: expected `String`, found `&str`
10869"#;
10870        let paths = parse_failing_paths_from_build_output(output);
10871        assert!(
10872            paths.iter().any(|p| p.contains("conversation.rs")),
10873            "should capture conversation.rs"
10874        );
10875        assert!(
10876            paths.iter().any(|p| p.contains("file_ops.rs")),
10877            "should capture file_ops.rs"
10878        );
10879        assert_eq!(paths.len(), 2, "no duplicates");
10880    }
10881
10882    #[test]
10883    fn failing_path_parser_ignores_macro_expansions() {
10884        let output = r#"
10885  --> <macro-expansion>:1:2
10886  --> src/real/file.rs:10:5
10887"#;
10888        let paths = parse_failing_paths_from_build_output(output);
10889        assert_eq!(paths.len(), 1);
10890        assert!(paths[0].contains("file.rs"));
10891    }
10892
10893    #[test]
10894    fn intent_router_picks_updates_for_update_questions() {
10895        assert_eq!(
10896            preferred_host_inspection_topic("is my PC up to date?"),
10897            Some("updates")
10898        );
10899        assert_eq!(
10900            preferred_host_inspection_topic("are there any pending Windows updates?"),
10901            Some("updates")
10902        );
10903        assert_eq!(
10904            preferred_host_inspection_topic("check for updates on my computer"),
10905            Some("updates")
10906        );
10907    }
10908
10909    #[test]
10910    fn intent_router_picks_security_for_antivirus_questions() {
10911        assert_eq!(
10912            preferred_host_inspection_topic("is my antivirus on?"),
10913            Some("security")
10914        );
10915        assert_eq!(
10916            preferred_host_inspection_topic("is Windows Defender running?"),
10917            Some("security")
10918        );
10919        assert_eq!(
10920            preferred_host_inspection_topic("is my PC protected?"),
10921            Some("security")
10922        );
10923    }
10924
10925    #[test]
10926    fn intent_router_picks_pending_reboot_for_restart_questions() {
10927        assert_eq!(
10928            preferred_host_inspection_topic("do I need to restart my PC?"),
10929            Some("pending_reboot")
10930        );
10931        assert_eq!(
10932            preferred_host_inspection_topic("is a reboot required?"),
10933            Some("pending_reboot")
10934        );
10935        assert_eq!(
10936            preferred_host_inspection_topic("is there a pending restart waiting?"),
10937            Some("pending_reboot")
10938        );
10939    }
10940
10941    #[test]
10942    fn intent_router_picks_disk_health_for_drive_health_questions() {
10943        assert_eq!(
10944            preferred_host_inspection_topic("is my hard drive dying?"),
10945            Some("disk_health")
10946        );
10947        assert_eq!(
10948            preferred_host_inspection_topic("check the disk health and SMART status"),
10949            Some("disk_health")
10950        );
10951        assert_eq!(
10952            preferred_host_inspection_topic("is my SSD healthy?"),
10953            Some("disk_health")
10954        );
10955    }
10956
10957    #[test]
10958    fn intent_router_picks_battery_for_battery_questions() {
10959        assert_eq!(
10960            preferred_host_inspection_topic("check my battery"),
10961            Some("battery")
10962        );
10963        assert_eq!(
10964            preferred_host_inspection_topic("how is my battery life?"),
10965            Some("battery")
10966        );
10967        assert_eq!(
10968            preferred_host_inspection_topic("what is my battery wear level?"),
10969            Some("battery")
10970        );
10971    }
10972
10973    #[test]
10974    fn intent_router_picks_recent_crashes_for_bsod_questions() {
10975        assert_eq!(
10976            preferred_host_inspection_topic("why did my PC restart by itself?"),
10977            Some("recent_crashes")
10978        );
10979        assert_eq!(
10980            preferred_host_inspection_topic("did my computer BSOD recently?"),
10981            Some("recent_crashes")
10982        );
10983        assert_eq!(
10984            preferred_host_inspection_topic("show me any recent app crashes"),
10985            Some("recent_crashes")
10986        );
10987    }
10988
10989    #[test]
10990    fn intent_router_picks_scheduled_tasks_for_task_questions() {
10991        assert_eq!(
10992            preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
10993            Some("scheduled_tasks")
10994        );
10995        assert_eq!(
10996            preferred_host_inspection_topic("show me the task scheduler"),
10997            Some("scheduled_tasks")
10998        );
10999    }
11000
11001    #[test]
11002    fn intent_router_picks_dev_conflicts_for_conflict_questions() {
11003        assert_eq!(
11004            preferred_host_inspection_topic("are there any dev environment conflicts?"),
11005            Some("dev_conflicts")
11006        );
11007        assert_eq!(
11008            preferred_host_inspection_topic("why is python pointing to the wrong version?"),
11009            Some("dev_conflicts")
11010        );
11011    }
11012
11013    #[test]
11014    fn shell_guard_catches_windows_update_commands() {
11015        assert!(shell_looks_like_structured_host_inspection(
11016            "Get-WindowsUpdateLog | Select-Object -Last 50"
11017        ));
11018        assert!(shell_looks_like_structured_host_inspection(
11019            "$sess = New-Object -ComObject Microsoft.Update.Session"
11020        ));
11021        assert!(shell_looks_like_structured_host_inspection(
11022            "Get-Service wuauserv"
11023        ));
11024        assert!(shell_looks_like_structured_host_inspection(
11025            "Get-MpComputerStatus"
11026        ));
11027        assert!(shell_looks_like_structured_host_inspection(
11028            "Get-PhysicalDisk"
11029        ));
11030        assert!(shell_looks_like_structured_host_inspection(
11031            "Get-CimInstance Win32_Battery"
11032        ));
11033        assert!(shell_looks_like_structured_host_inspection(
11034            "Get-WinEvent -FilterHashtable @{Id=41}"
11035        ));
11036        assert!(shell_looks_like_structured_host_inspection(
11037            "Get-ScheduledTask | Where-Object State -ne Disabled"
11038        ));
11039    }
11040
11041    #[test]
11042    fn intent_router_picks_permissions_for_acl_questions() {
11043        assert_eq!(
11044            preferred_host_inspection_topic("who has permission to access the downloads folder?"),
11045            Some("permissions")
11046        );
11047        assert_eq!(
11048            preferred_host_inspection_topic("audit the ntfs permissions for this path"),
11049            Some("permissions")
11050        );
11051    }
11052
11053    #[test]
11054    fn intent_router_picks_login_history_for_logon_questions() {
11055        assert_eq!(
11056            preferred_host_inspection_topic("who logged in recently on this machine?"),
11057            Some("login_history")
11058        );
11059        assert_eq!(
11060            preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
11061            Some("login_history")
11062        );
11063    }
11064
11065    #[test]
11066    fn intent_router_picks_share_access_for_unc_questions() {
11067        assert_eq!(
11068            preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
11069            Some("share_access")
11070        );
11071        assert_eq!(
11072            preferred_host_inspection_topic("test accessibility of a network share"),
11073            Some("share_access")
11074        );
11075    }
11076
11077    #[test]
11078    fn intent_router_picks_registry_audit_for_persistence_questions() {
11079        assert_eq!(
11080            preferred_host_inspection_topic(
11081                "audit my registry for persistence hacks or debugger hijacking"
11082            ),
11083            Some("registry_audit")
11084        );
11085        assert_eq!(
11086            preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
11087            Some("registry_audit")
11088        );
11089    }
11090
11091    #[test]
11092    fn intent_router_picks_network_stats_for_mbps_questions() {
11093        assert_eq!(
11094            preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
11095            Some("network_stats")
11096        );
11097    }
11098
11099    #[test]
11100    fn intent_router_picks_processes_for_cpu_percentage_questions() {
11101        assert_eq!(
11102            preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
11103            Some("processes")
11104        );
11105    }
11106
11107    #[test]
11108    fn intent_router_picks_log_check_for_recent_window_questions() {
11109        assert_eq!(
11110            preferred_host_inspection_topic("show me system errors from the last 2 hours"),
11111            Some("log_check")
11112        );
11113    }
11114
11115    #[test]
11116    fn intent_router_picks_battery_for_health_and_cycles() {
11117        assert_eq!(
11118            preferred_host_inspection_topic("check my battery health and cycle count"),
11119            Some("battery")
11120        );
11121    }
11122
11123    #[test]
11124    fn intent_router_picks_thermal_for_throttling_questions() {
11125        assert_eq!(
11126            preferred_host_inspection_topic(
11127                "why is my laptop slow? check for overheating or throttling"
11128            ),
11129            Some("thermal")
11130        );
11131        assert_eq!(
11132            preferred_host_inspection_topic("show me the current cpu temp"),
11133            Some("thermal")
11134        );
11135    }
11136
11137    #[test]
11138    fn intent_router_picks_activation_for_genuine_questions() {
11139        assert_eq!(
11140            preferred_host_inspection_topic("is my windows genuine? check activation status"),
11141            Some("activation")
11142        );
11143        assert_eq!(
11144            preferred_host_inspection_topic("run slmgr to check my license state"),
11145            Some("activation")
11146        );
11147    }
11148
11149    #[test]
11150    fn intent_router_picks_patch_history_for_hotfix_questions() {
11151        assert_eq!(
11152            preferred_host_inspection_topic("show me the recently installed hotfixes"),
11153            Some("patch_history")
11154        );
11155        assert_eq!(
11156            preferred_host_inspection_topic(
11157                "list the windows update patch history for the last 48 hours"
11158            ),
11159            Some("patch_history")
11160        );
11161    }
11162
11163    #[test]
11164    fn intent_router_detects_multiple_symptoms_for_prerun() {
11165        let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
11166        assert!(topics.contains(&"thermal"));
11167        assert!(topics.contains(&"resource_load"));
11168        assert!(topics.contains(&"storage"));
11169        assert!(topics.len() >= 3);
11170    }
11171
11172    #[test]
11173    fn parse_unload_target_supports_current_and_all() {
11174        assert_eq!(
11175            ConversationManager::parse_unload_target("current").unwrap(),
11176            (None, false)
11177        );
11178        assert_eq!(
11179            ConversationManager::parse_unload_target("all").unwrap(),
11180            (None, true)
11181        );
11182        assert_eq!(
11183            ConversationManager::parse_unload_target("qwen/qwen3.5-9b").unwrap(),
11184            (Some("qwen/qwen3.5-9b".to_string()), false)
11185        );
11186    }
11187
11188    #[test]
11189    fn provider_model_controls_summary_mentions_ollama_limits() {
11190        let ollama = ConversationManager::provider_model_controls_summary("Ollama");
11191        assert!(ollama.contains("Ollama supports coding and embed model load/list/unload"));
11192        let lms = ConversationManager::provider_model_controls_summary("LM Studio");
11193        assert!(lms.contains("LM Studio supports coding and embed model load/unload"));
11194    }
11195}