Skip to main content

hematite/agent/
routing.rs

1use super::conversation::WorkflowMode;
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4pub(crate) enum QueryIntentClass {
5    ProductTruth,
6    RuntimeDiagnosis,
7    RepoArchitecture,
8    Toolchain,
9    Capability,
10    Implementation,
11    Unknown,
12}
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(crate) enum DirectAnswerKind {
16    About,
17    LanguageCapability,
18    UnsafeWorkflowPressure,
19    SessionMemory,
20    RecoveryRecipes,
21    McpLifecycle,
22    AuthorizationPolicy,
23    ToolClasses,
24    ToolRegistryOwnership,
25    SessionResetSemantics,
26    ProductSurface,
27    ReasoningSplit,
28    Identity,
29    WorkflowModes,
30    GemmaNative,
31    GemmaNativeSettings,
32    VerifyProfiles,
33    Toolchain,
34    ArchitectSessionResetPlan,
35}
36
37#[derive(Clone, Copy, Debug)]
38pub(crate) struct QueryIntent {
39    pub(crate) primary_class: QueryIntentClass,
40    pub(crate) direct_answer: Option<DirectAnswerKind>,
41    pub(crate) grounded_trace_mode: bool,
42    pub(crate) capability_mode: bool,
43    pub(crate) capability_needs_repo: bool,
44    pub(crate) toolchain_mode: bool,
45    pub(crate) host_inspection_mode: bool,
46    pub(crate) maintainer_workflow_mode: bool,
47    pub(crate) workspace_workflow_mode: bool,
48    pub(crate) architecture_overview_mode: bool,
49}
50
51fn contains_any(haystack: &str, needles: &[&str]) -> bool {
52    needles.iter().any(|needle| haystack.contains(needle))
53}
54
55fn contains_all(haystack: &str, needles: &[&str]) -> bool {
56    needles.iter().all(|needle| haystack.contains(needle))
57}
58
59fn mentions_reset_commands(lower: &str) -> bool {
60    contains_all(lower, &["/clear", "/new", "/forget"])
61}
62
63fn mentions_stable_product_surface(lower: &str) -> bool {
64    contains_any(
65        lower,
66        &[
67            "stable product-surface question",
68            "stable product surface question",
69            "stable product-surface questions",
70            "stable product surface questions",
71        ],
72    )
73}
74
75fn mentions_product_truth_routing(lower: &str) -> bool {
76    let asks_decision_policy = contains_any(
77        lower,
78        &[
79            "how hematite decides",
80            "how does hematite decide",
81            "decides whether",
82            "decide whether",
83        ],
84    );
85    let asks_direct_vs_inspect_split = contains_any(
86        lower,
87        &[
88            "answered as stable product truth",
89            "stable product truth",
90            "stable product behavior",
91            "answer directly",
92            "direct answer",
93            "inspect the repository",
94            "inspect repository",
95            "repository implementation",
96            "repo implementation",
97        ],
98    );
99    asks_decision_policy && asks_direct_vs_inspect_split
100}
101
102fn mentions_broad_system_walkthrough(lower: &str) -> bool {
103    let asks_walkthrough = contains_any(
104        lower,
105        &[
106            "walk me through",
107            "walk through",
108            "how hematite is wired",
109            "understand how hematite is wired",
110            "major runtime pieces",
111            "normal message moves",
112            "moves from the tui to the model and back",
113        ],
114    );
115    let asks_multiple_runtime_areas = contains_any(
116        lower,
117        &[
118            "session recovery",
119            "tool policy",
120            "mcp state",
121            "mcp policy",
122            "files own the major runtime pieces",
123            "which files own",
124            "where session recovery",
125            "where tool policy",
126            "where mcp state",
127        ],
128    );
129    asks_walkthrough && asks_multiple_runtime_areas
130}
131
132fn mentions_capability_question(lower: &str) -> bool {
133    contains_any(
134        lower,
135        &[
136            "what can you do",
137            "what are you capable",
138            "can you make projects",
139            "can you build projects",
140            "do you know other coding languages",
141            "other coding languages",
142            "what languages",
143            "can you use the internet",
144            "internet research capabilities",
145            "what tools do you have",
146        ],
147    )
148}
149
150fn mentions_creator_question(lower: &str) -> bool {
151    contains_any(
152        lower,
153        &[
154            "who created you",
155            "who built you",
156            "who made you",
157            "who developed you",
158            "who engineered you",
159            "who engineered your architecture",
160            "who created hematite",
161            "who built hematite",
162            "who developed hematite",
163            "who engineered hematite",
164            "who maintains hematite",
165            "who authored hematite",
166            "who is the author",
167            "who wrote this",
168            "who made this app",
169        ],
170    )
171}
172
173fn capability_question_requires_repo_inspection(lower: &str) -> bool {
174    contains_any(
175        lower,
176        &[
177            "this repo",
178            "this repository",
179            "codebase",
180            "which files",
181            "implementation",
182            "in this project",
183        ],
184    )
185}
186
187fn mentions_host_inspection_question(lower: &str) -> bool {
188    let host_scope = contains_any(
189        lower,
190        &[
191            "path",
192            "package manager",
193            "package managers",
194            "env doctor",
195            "environment doctor",
196            "pip",
197            "winget",
198            "choco",
199            "scoop",
200            "network",
201            "adapter",
202            "dns",
203            "gateway",
204            "ip address",
205            "ipconfig",
206            "wifi",
207            "ethernet",
208            "service",
209            "services",
210            "daemon",
211            "startup type",
212            "process",
213            "processes",
214            "task manager",
215            "ram",
216            "cpu",
217            "memory",
218            "developer tools",
219            "toolchains",
220            "installed",
221            "desktop",
222            "downloads",
223            "folder",
224            "directory",
225            "local development",
226            "machine",
227            "computer",
228        ],
229    );
230    let host_action = contains_any(
231        lower,
232        &[
233            "inspect",
234            "count",
235            "tell me",
236            "summarize",
237            "how big",
238            "biggest",
239            "versions",
240            "duplicate",
241            "missing",
242            "ready",
243            "fix",
244            "repair",
245            "resolve",
246            "troubleshoot",
247        ],
248    );
249
250    host_scope && host_action
251}
252
253pub(crate) fn preferred_host_inspection_topic(user_input: &str) -> Option<&'static str> {
254    let lower = user_input.to_lowercase();
255    let asks_fix_plan = (lower.contains("fix")
256        || lower.contains("repair")
257        || lower.contains("resolve")
258        || lower.contains("troubleshoot"))
259        && (lower.contains("cargo")
260            || lower.contains("path")
261            || lower.contains("package manager")
262            || lower.contains("toolchain")
263            || lower.contains("port ")
264            || lower.contains("already in use")
265            || lower.contains("lm studio")
266            || lower.contains("localhost:1234")
267            || lower.contains("embedding model")
268            || lower.contains("no coding model loaded"));
269    let asks_path = lower.contains("path");
270    let asks_env_doctor = lower.contains("env doctor")
271        || lower.contains("environment doctor")
272        || lower.contains("package manager")
273        || lower.contains("package managers")
274        || lower.contains("shims")
275        || lower.contains("path drift")
276        || (lower.contains("dev machine") && lower.contains("off"))
277        || (lower.contains("environment") && lower.contains("sane"));
278    let asks_network = lower.contains("network")
279        || lower.contains("adapter")
280        || lower.contains("dns")
281        || lower.contains("gateway")
282        || lower.contains("ip address")
283        || lower.contains("ipconfig")
284        || lower.contains("wifi")
285        || lower.contains("ethernet")
286        || lower.contains("subnet");
287    let asks_services = lower.contains("service")
288        || lower.contains("services")
289        || lower.contains("daemon")
290        || lower.contains("startup type")
291        || lower.contains("background service")
292        || lower.contains("windows service")
293        || lower.contains("systemctl")
294        || lower.contains("get-service");
295    let asks_processes = lower.contains("process")
296        || lower.contains("processes")
297        || lower.contains("task manager")
298        || lower.contains("what is running")
299        || lower.contains("what's running")
300        || lower.contains("using my ram")
301        || lower.contains("using ram")
302        || lower.contains("using my cpu")
303        || lower.contains("top memory")
304        || lower.contains("top ram")
305        || lower.contains("high memory");
306    let asks_toolchains = lower.contains("developer tools")
307        || lower.contains("toolchains")
308        || (lower.contains("installed") && lower.contains("version"))
309        || (lower.contains("detect") && lower.contains("version"));
310    let asks_ports = lower.contains("listening on port")
311        || lower.contains("listening port")
312        || lower.contains("open port")
313        || lower.contains("port 3000")
314        || lower.contains("port ")
315        || lower.contains("listening on ")
316        || lower.contains("exposed")
317        || lower.contains("what is listening");
318    let asks_repo_doctor = lower.contains("repo doctor")
319        || lower.contains("repository doctor")
320        || lower.contains("workspace health")
321        || lower.contains("repo health")
322        || lower.contains("workspace sanity")
323        || (lower.contains("git state")
324            && (lower.contains("release artifacts")
325                || lower.contains("build markers")
326                || lower.contains("hematite memory")));
327    let asks_directory = lower.contains("directory")
328        || lower.contains("folder")
329        || lower.contains("how big")
330        || lower.contains("biggest");
331    let asks_broad_readiness = lower.contains("local development")
332        || lower.contains("ready for local development")
333        || (lower.contains("machine") && lower.contains("ready"))
334        || (lower.contains("computer") && lower.contains("ready"));
335
336    if asks_fix_plan {
337        Some("fix_plan")
338    } else if (asks_path && asks_toolchains)
339        || (mentions_host_inspection_question(&lower) && asks_broad_readiness)
340    {
341        Some("summary")
342    } else if asks_env_doctor {
343        Some("env_doctor")
344    } else if asks_network {
345        Some("network")
346    } else if asks_services {
347        Some("services")
348    } else if asks_processes {
349        Some("processes")
350    } else if asks_ports {
351        Some("ports")
352    } else if asks_repo_doctor {
353        Some("repo_doctor")
354    } else if lower.contains("desktop") {
355        Some("desktop")
356    } else if lower.contains("downloads") {
357        Some("downloads")
358    } else if asks_path {
359        Some("path")
360    } else if asks_toolchains {
361        Some("toolchains")
362    } else if asks_directory {
363        Some("directory")
364    } else if mentions_host_inspection_question(&lower) {
365        Some("summary")
366    } else {
367        None
368    }
369}
370
371pub(crate) fn preferred_maintainer_workflow(user_input: &str) -> Option<&'static str> {
372    let lower = user_input.to_ascii_lowercase();
373    let asks_cleanup = contains_any(
374        &lower,
375        &[
376            "run my cleanup",
377            "run the cleanup",
378            "run cleanup",
379            "deep clean",
380            "prune dist",
381            "clean.ps1",
382            "cleanup script",
383            "cleanup workflow",
384            "clean up scripts",
385        ],
386    );
387    let asks_package = contains_any(
388        &lower,
389        &[
390            "rebuild local portable",
391            "rebuild the portable",
392            "run the local build",
393            "run the portable",
394            "package-windows.ps1",
395            "package windows",
396            "build installer",
397            "overwrite the portable",
398            "refresh the portable",
399            "update path",
400            "update path with the portable",
401        ],
402    );
403    let asks_release = contains_any(
404        &lower,
405        &[
406            "run the release flow",
407            "regular workflow",
408            "cut the release",
409            "ship it",
410            "release.ps1",
411            "bump to ",
412            "tag it",
413            "full tag and everything",
414            "publish crates",
415        ],
416    );
417
418    if asks_cleanup {
419        Some("clean")
420    } else if asks_package {
421        Some("package_windows")
422    } else if asks_release {
423        Some("release")
424    } else {
425        None
426    }
427}
428
429pub(crate) fn preferred_workspace_workflow(user_input: &str) -> Option<&'static str> {
430    let lower = user_input.to_ascii_lowercase();
431    let asks_project_scope = contains_any(
432        &lower,
433        &[
434            "this repo",
435            "this repository",
436            "this project",
437            "current project",
438            "current repo",
439            "workspace",
440            "in this folder",
441            "here",
442        ],
443    );
444    let asks_build = contains_any(
445        &lower,
446        &[
447            "run the build",
448            "build this project",
449            "build this repo",
450            "run build",
451            "compile this project",
452            "cargo build",
453            "npm run build",
454            "pnpm run build",
455            "yarn build",
456            "go build",
457            "gradlew build",
458        ],
459    );
460    let asks_test = contains_any(
461        &lower,
462        &[
463            "run the tests",
464            "run tests",
465            "test this project",
466            "test this repo",
467            "run the test suite",
468            "cargo test",
469            "npm test",
470            "pnpm test",
471            "yarn test",
472            "pytest",
473            "go test",
474            "gradlew test",
475        ],
476    );
477    let asks_lint = contains_any(
478        &lower,
479        &[
480            "run lint",
481            "lint this project",
482            "lint this repo",
483            "cargo clippy",
484            "npm run lint",
485            "pnpm run lint",
486            "yarn lint",
487        ],
488    );
489    let asks_fix = contains_any(
490        &lower,
491        &[
492            "run fix",
493            "fix formatting",
494            "run formatter",
495            "cargo fmt",
496            "npm run fix",
497            "pnpm run fix",
498            "yarn fix",
499        ],
500    );
501    let asks_script = contains_any(
502        &lower,
503        &[
504            "npm run ",
505            "pnpm run ",
506            "yarn ",
507            "bun run ",
508            "make ",
509            "just ",
510            "task ",
511            "scripts/",
512            ".\\scripts\\",
513            "./scripts/",
514            ".ps1",
515            ".sh",
516            ".py",
517            ".cmd",
518            ".bat",
519        ],
520    );
521
522    if asks_build
523        && (asks_project_scope
524            || !contains_any(&lower, &["release.ps1", "package-windows.ps1", "clean.ps1"]))
525    {
526        Some("build")
527    } else if asks_test && asks_project_scope {
528        Some("test")
529    } else if asks_lint && asks_project_scope {
530        Some("lint")
531    } else if asks_fix && asks_project_scope {
532        Some("fix")
533    } else if asks_script && !preferred_maintainer_workflow(user_input).is_some() {
534        Some("script")
535    } else if (asks_test || asks_lint || asks_fix)
536        && !preferred_maintainer_workflow(user_input).is_some()
537    {
538        Some(if asks_test {
539            "test"
540        } else if asks_lint {
541            "lint"
542        } else {
543            "fix"
544        })
545    } else {
546        None
547    }
548}
549
550pub(crate) fn looks_like_mutation_request(user_input: &str) -> bool {
551    let lower = user_input.to_lowercase();
552    [
553        "fix ",
554        "change ",
555        "edit ",
556        "modify ",
557        "update ",
558        "rename ",
559        "refactor ",
560        "patch ",
561        "rewrite ",
562        "implement ",
563        "create a file",
564        "create file",
565        "add a file",
566        "delete ",
567        "remove ",
568        "make the change",
569    ]
570    .iter()
571    .any(|needle| lower.contains(needle))
572}
573
574pub(crate) fn classify_query_intent(workflow_mode: WorkflowMode, user_input: &str) -> QueryIntent {
575    let lower = user_input.to_lowercase();
576    let trimmed = user_input.trim().to_ascii_lowercase();
577
578    let mentions_runtime_trace = contains_any(
579        &lower,
580        &[
581            "trace",
582            "how does",
583            "what are the main runtime subsystems",
584            "how does a user message move",
585            "separate normal assistant output",
586            "session reset behavior",
587            "file references",
588            "event types",
589            "channels",
590        ],
591    );
592    let anti_guess = contains_any(&lower, &["do not guess", "if you are unsure"]);
593    let capability_mode = mentions_capability_question(&lower);
594    let capability_needs_repo =
595        capability_mode && capability_question_requires_repo_inspection(&lower);
596    let host_inspection_mode = preferred_host_inspection_topic(&lower).is_some();
597    let maintainer_workflow_mode = preferred_maintainer_workflow(&lower).is_some();
598    let workspace_workflow_mode =
599        preferred_workspace_workflow(&lower).is_some() && !maintainer_workflow_mode;
600    let toolchain_mode = contains_any(
601        &lower,
602        &[
603            "tooling discipline",
604            "best read-only toolchain",
605            "identify the best tools you actually have",
606            "concrete read-only investigation plan",
607            "do not execute the plan",
608            "available repo-inspection tools",
609            "tool choice discipline",
610            "what tools would you choose first",
611        ],
612    ) || (lower.contains("which tools") && lower.contains("why"))
613        || (lower.contains("when would you choose") && lower.contains("tool"));
614    let architecture_overview_mode = {
615        let architecture_signals = contains_any(
616            &lower,
617            &[
618                "architecture overview",
619                "architecture walkthrough",
620                "full architecture",
621                "runtime walkthrough",
622                "control flow",
623                "tool routing",
624                "workflow modes",
625                "repo map behavior",
626                "mcp policy",
627                "prompt budgeting",
628                "compaction",
629                "file ownership",
630                "owner file",
631                "project structure",
632                "repository structure",
633            ],
634        );
635        let broad = contains_any(
636            &lower,
637            &[
638                "full detailed",
639                "all in one answer",
640                "concrete file ownership",
641                "walk me through",
642                "major runtime pieces",
643                "which files own",
644                "how",
645                "explain",
646                "overview",
647            ],
648        );
649        (architecture_signals && broad)
650            || (lower.contains("runtime")
651                && lower.contains("workflow")
652                && (lower.contains("architecture") || lower.contains("tool routing")))
653            || mentions_broad_system_walkthrough(&lower)
654    };
655
656    let direct_answer = if trimmed == "/about" || mentions_creator_question(&lower) {
657        Some(DirectAnswerKind::About)
658    } else if matches!(
659        trimmed.as_str(),
660        "who are you" | "who are you?" | "what are you" | "what are you?"
661    ) || (lower.contains("what is hematite") && !lower.contains("lm studio"))
662    {
663        Some(DirectAnswerKind::Identity)
664    } else if (mentions_stable_product_surface(&lower) || mentions_product_truth_routing(&lower))
665        && contains_any(
666            &lower,
667            &[
668                "how hematite answers",
669                "how does hematite answer",
670                "how hematite handles",
671                "how does hematite handle",
672                "how hematite decides",
673                "how does hematite decide",
674                "decides whether",
675                "decide whether",
676            ],
677        )
678    {
679        Some(DirectAnswerKind::ProductSurface)
680    } else if mentions_reset_commands(&lower)
681        && contains_any(
682            &lower,
683            &[
684                "exact difference",
685                "difference between",
686                "explain the exact difference",
687                "what is the difference",
688            ],
689        )
690    {
691        Some(DirectAnswerKind::SessionResetSemantics)
692    } else if (lower.contains("reasoning output") || lower.contains("reasoning"))
693        && contains_any(
694            &lower,
695            &["visible chat output", "visible chat", "chat output"],
696        )
697    {
698        Some(DirectAnswerKind::ReasoningSplit)
699    } else if lower.contains("/ask")
700        && lower.contains("/code")
701        && lower.contains("/architect")
702        && lower.contains("/read-only")
703        && lower.contains("/auto")
704        && contains_any(&lower, &["difference", "differences", "what are"])
705    {
706        Some(DirectAnswerKind::WorkflowModes)
707    } else if lower.contains(".hematite/settings.json")
708        && lower.contains("gemma_native_auto")
709        && lower.contains("gemma_native_formatting")
710    {
711        Some(DirectAnswerKind::GemmaNativeSettings)
712    } else if contains_any(
713        &lower,
714        &[
715            "skip verification",
716            "skip build verification",
717            "commit it immediately",
718            "commit immediately",
719        ],
720    ) && contains_any(
721        &lower,
722        &[
723            "make a code change",
724            "make the change",
725            "change the code",
726            "edit the code",
727            "edit a file",
728            "implement",
729        ],
730    ) {
731        Some(DirectAnswerKind::UnsafeWorkflowPressure)
732    } else if contains_any(&lower, &["/gemma-native", "gemma native"])
733        && contains_any(&lower, &["what does", "what is", "how does", "what do"])
734    {
735        Some(DirectAnswerKind::GemmaNative)
736    } else if lower.contains("verify_build")
737        && lower.contains(".hematite/settings.json")
738        && contains_any(
739            &lower,
740            &["build", "test", "lint", "fix", "verification commands"],
741        )
742    {
743        Some(DirectAnswerKind::VerifyProfiles)
744    } else if (lower.contains("carry forward by default")
745        || lower.contains("session memory should you carry forward")
746        || (lower.contains("carry forward")
747            && contains_any(
748                &lower,
749                &[
750                    "besides the active task",
751                    "blocker",
752                    "compacts",
753                    "recovers from a blocker",
754                    "session state",
755                ],
756            )))
757        && contains_any(
758            &lower,
759            &[
760                "restarted hematite",
761                "restarted",
762                "avoid carrying forward",
763                "session state",
764                "active task",
765                "blocker",
766                "compacts",
767                "recovers from a blocker",
768            ],
769        )
770    {
771        Some(DirectAnswerKind::SessionMemory)
772    } else if contains_any(
773        &lower,
774        &[
775            "recovery recipe",
776            "recovery recipes",
777            "recovery step",
778            "recovery steps",
779        ],
780    ) && contains_any(
781        &lower,
782        &[
783            "blocker",
784            "runtime failure",
785            "degrades",
786            "context window",
787            "context-window",
788            "operator",
789        ],
790    ) {
791        Some(DirectAnswerKind::RecoveryRecipes)
792    } else if !architecture_overview_mode
793        && contains_any(
794            &lower,
795            &[
796                "mcp server health",
797                "mcp runtime state",
798                "mcp lifecycle",
799                "mcp state",
800                "mcp healthy",
801                "mcp degraded",
802                "mcp failed",
803            ],
804        )
805    {
806        Some(DirectAnswerKind::McpLifecycle)
807    } else if contains_any(
808        &lower,
809        &[
810            "allowed, denied, or require approval",
811            "allowed denied or require approval",
812            "allow, ask, or deny",
813            "tool call should be allowed",
814            "authorization logic",
815            "workspace trust",
816            "trust-allowlisted",
817        ],
818    ) {
819        Some(DirectAnswerKind::AuthorizationPolicy)
820    } else if contains_any(
821        &lower,
822        &[
823            "tool classes",
824            "tool class",
825            "flat tool list",
826            "runtime tool classes",
827            "different runtime tool classes",
828        ],
829    ) || (lower.contains("repo reads")
830        && lower.contains("repo writes")
831        && contains_any(
832            &lower,
833            &[
834                "verification tools",
835                "git tools",
836                "external mcp tools",
837                "different runtime",
838            ],
839        ))
840    {
841        Some(DirectAnswerKind::ToolClasses)
842    } else if contains_any(
843        &lower,
844        &[
845            "built-in tool catalog",
846            "builtin tool catalog",
847            "builtin-tool dispatch",
848            "built-in tool dispatch",
849            "tool registry ownership",
850            "which file now owns",
851        ],
852    ) && contains_any(
853        &lower,
854        &[
855            "tool catalog",
856            "dispatch path",
857            "dispatch",
858            "tool registry",
859            "owns",
860        ],
861    ) {
862        Some(DirectAnswerKind::ToolRegistryOwnership)
863    } else if (lower.contains("other coding languages")
864        || lower.contains("what languages")
865        || lower.contains("know other languages"))
866        && contains_any(
867            &lower,
868            &[
869                "capable of making projects",
870                "can you make projects",
871                "can you build projects",
872            ],
873        )
874    {
875        Some(DirectAnswerKind::LanguageCapability)
876    } else if workflow_mode == WorkflowMode::Architect
877        && (lower.contains("session reset")
878            || (lower.contains("/clear") && lower.contains("/new") && lower.contains("/forget")))
879        && contains_any(&lower, &["redesign", "clearer", "easier", "understand"])
880    {
881        Some(DirectAnswerKind::ArchitectSessionResetPlan)
882    } else if toolchain_mode
883        && lower.contains("read-only")
884        && contains_any(
885            &lower,
886            &[
887                "tooling discipline",
888                "investigation plan",
889                "best read-only toolchain",
890                "tool choice discipline",
891                "what tools would you choose first",
892            ],
893        )
894    {
895        Some(DirectAnswerKind::Toolchain)
896    } else {
897        None
898    };
899
900    let primary_class = if direct_answer.is_some()
901        || mentions_stable_product_surface(&lower)
902        || mentions_product_truth_routing(&lower)
903    {
904        QueryIntentClass::ProductTruth
905    } else if architecture_overview_mode {
906        QueryIntentClass::RepoArchitecture
907    } else if toolchain_mode {
908        QueryIntentClass::Toolchain
909    } else if capability_mode {
910        QueryIntentClass::Capability
911    } else if mentions_runtime_trace || anti_guess || lower.contains("read-only") {
912        QueryIntentClass::RuntimeDiagnosis
913    } else if looks_like_mutation_request(user_input) {
914        QueryIntentClass::Implementation
915    } else {
916        QueryIntentClass::Unknown
917    };
918
919    QueryIntent {
920        primary_class,
921        direct_answer,
922        grounded_trace_mode: mentions_runtime_trace || lower.contains("read-only") || anti_guess,
923        capability_mode,
924        capability_needs_repo,
925        toolchain_mode,
926        host_inspection_mode,
927        maintainer_workflow_mode,
928        workspace_workflow_mode,
929        architecture_overview_mode,
930    }
931}
932
933pub(crate) fn is_capability_probe_tool(name: &str) -> bool {
934    matches!(
935        name,
936        "read_file"
937            | "inspect_lines"
938            | "list_files"
939            | "grep_files"
940            | "lsp_definitions"
941            | "lsp_references"
942            | "lsp_hover"
943            | "lsp_search_symbol"
944            | "lsp_get_diagnostics"
945            | "trace_runtime_flow"
946            | "auto_pin_context"
947            | "list_pinned"
948    )
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn classify_query_intent_routes_creator_questions_to_about() {
957        let intent = classify_query_intent(WorkflowMode::Auto, "Who created Hematite?");
958        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::About));
959
960        let intent = classify_query_intent(WorkflowMode::Auto, "/about");
961        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::About));
962    }
963
964    #[test]
965    fn classify_query_intent_marks_maintainer_workflow_requests() {
966        let intent = classify_query_intent(
967            WorkflowMode::Auto,
968            "Run my cleanup scripts and prune old artifacts.",
969        );
970        assert!(intent.maintainer_workflow_mode);
971        assert_eq!(
972            preferred_maintainer_workflow("Rebuild the local portable and update PATH."),
973            Some("package_windows")
974        );
975        assert_eq!(
976            preferred_maintainer_workflow("Run the release flow and publish crates."),
977            Some("release")
978        );
979    }
980
981    #[test]
982    fn classify_query_intent_marks_workspace_workflow_requests() {
983        let intent = classify_query_intent(WorkflowMode::Auto, "Run the tests in this project.");
984        assert!(intent.workspace_workflow_mode);
985        assert_eq!(
986            preferred_workspace_workflow("Run the tests in this project."),
987            Some("test")
988        );
989        assert_eq!(
990            preferred_workspace_workflow("Run npm run dev in this repo."),
991            Some("script")
992        );
993    }
994}