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