Skip to main content

hematite/agent/
workspace_profile.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub struct WorkspaceProfile {
8    pub workspace_mode: String,
9    pub primary_stack: Option<String>,
10    #[serde(default)]
11    pub stack_signals: Vec<String>,
12    #[serde(default)]
13    pub package_managers: Vec<String>,
14    #[serde(default)]
15    pub important_paths: Vec<String>,
16    #[serde(default)]
17    pub ignored_paths: Vec<String>,
18    pub verify_profile: Option<String>,
19    pub build_hint: Option<String>,
20    pub test_hint: Option<String>,
21    #[serde(default)]
22    pub runtime_contract: Option<RuntimeContract>,
23    pub summary: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct RuntimeContract {
28    pub loop_family: String,
29    pub app_kind: String,
30    pub framework_hint: Option<String>,
31    #[serde(default)]
32    pub preferred_workflows: Vec<String>,
33    #[serde(default)]
34    pub delivery_phases: Vec<String>,
35    #[serde(default)]
36    pub verification_workflows: Vec<String>,
37    #[serde(default)]
38    pub quality_gates: Vec<String>,
39    pub local_url_hint: Option<String>,
40    #[serde(default)]
41    pub route_hints: Vec<String>,
42}
43
44pub fn workspace_profile_path(root: &Path) -> PathBuf {
45    // In OS shortcut directories (Desktop, Downloads, etc.) write to the global dir
46    // so no .hematite/ folder is created there.
47    if crate::tools::file_ops::is_os_shortcut_directory(root) {
48        return crate::tools::file_ops::hematite_dir().join("workspace_profile.json");
49    }
50    root.join(".hematite").join("workspace_profile.json")
51}
52
53pub fn ensure_workspace_profile(root: &Path) -> Result<WorkspaceProfile, String> {
54    let profile = detect_workspace_profile(root);
55    let path = workspace_profile_path(root);
56    if let Some(parent) = path.parent() {
57        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
58    }
59
60    let json = serde_json::to_string_pretty(&profile).map_err(|e| e.to_string())?;
61    let existing = std::fs::read_to_string(&path).ok();
62    if existing.as_deref() != Some(json.as_str()) {
63        std::fs::write(&path, json).map_err(|e| e.to_string())?;
64    }
65
66    Ok(profile)
67}
68
69pub fn load_workspace_profile(root: &Path) -> Option<WorkspaceProfile> {
70    let path = workspace_profile_path(root);
71    std::fs::read_to_string(path)
72        .ok()
73        .and_then(|raw| serde_json::from_str(&raw).ok())
74}
75
76pub fn profile_prompt_block(root: &Path) -> Option<String> {
77    let profile = load_workspace_profile(root).unwrap_or_else(|| detect_workspace_profile(root));
78    if profile.summary.trim().is_empty() {
79        return None;
80    }
81
82    let mut lines = vec![format!("Summary: {}", profile.summary)];
83    if let Some(stack) = &profile.primary_stack {
84        lines.push(format!("Primary stack: {}", stack));
85    }
86    if !profile.package_managers.is_empty() {
87        lines.push(format!(
88            "Package managers: {}",
89            profile.package_managers.join(", ")
90        ));
91    }
92    if let Some(profile_name) = &profile.verify_profile {
93        lines.push(format!("Verify profile: {}", profile_name));
94    }
95    if let Some(build_hint) = &profile.build_hint {
96        lines.push(format!("Build hint: {}", build_hint));
97    }
98    if let Some(test_hint) = &profile.test_hint {
99        lines.push(format!("Test hint: {}", test_hint));
100    }
101    if let Some(contract) = &profile.runtime_contract {
102        lines.push(format!("Loop family: {}", contract.loop_family));
103        lines.push(format!("App kind: {}", contract.app_kind));
104        if let Some(framework) = &contract.framework_hint {
105            lines.push(format!("Framework hint: {}", framework));
106        }
107        if let Some(url) = &contract.local_url_hint {
108            lines.push(format!("Local URL hint: {}", url));
109        }
110        if !contract.preferred_workflows.is_empty() {
111            lines.push(format!(
112                "Preferred workflows: {}",
113                contract.preferred_workflows.join(", ")
114            ));
115        }
116    }
117    if !profile.important_paths.is_empty() {
118        lines.push(format!(
119            "Important paths: {}",
120            profile.important_paths.join(", ")
121        ));
122    }
123    if !profile.ignored_paths.is_empty() {
124        lines.push(format!(
125            "Ignore noise from: {}",
126            profile.ignored_paths.join(", ")
127        ));
128    }
129
130    Some(format!(
131        "# Workspace Profile (auto-generated)\n{}",
132        lines.join("\n")
133    ))
134}
135
136pub fn profile_strategy_prompt_block(root: &Path) -> Option<String> {
137    let profile = load_workspace_profile(root).unwrap_or_else(|| detect_workspace_profile(root));
138    let contract = profile.runtime_contract?;
139    let mut lines = Vec::new();
140    lines.push(format!(
141        "Treat this workspace as a `{}` control loop, not a blank slate.",
142        contract.app_kind
143    ));
144    if !contract.delivery_phases.is_empty() {
145        lines.push(format!(
146            "Work in this order: {}.",
147            contract.delivery_phases.join(" -> ")
148        ));
149    }
150    if !contract.verification_workflows.is_empty() {
151        lines.push(format!(
152            "Automatic proof should come from: {}.",
153            contract.verification_workflows.join(", ")
154        ));
155    }
156    if !contract.quality_gates.is_empty() {
157        lines.push(format!(
158            "Do not consider the task complete until these gates hold: {}.",
159            contract.quality_gates.join("; ")
160        ));
161    }
162    if let Some(url) = contract.local_url_hint {
163        lines.push(format!("Local runtime hint: {}.", url));
164    }
165    if !contract.route_hints.is_empty() {
166        lines.push(format!(
167            "High-signal routes: {}.",
168            contract.route_hints.join(", ")
169        ));
170    }
171    Some(format!(
172        "# Stack Delivery Contract (auto-generated)\n{}",
173        lines.join("\n")
174    ))
175}
176
177pub fn profile_report(root: &Path) -> String {
178    let profile = load_workspace_profile(root).unwrap_or_else(|| detect_workspace_profile(root));
179    let path = workspace_profile_path(root);
180
181    let mut out = String::new();
182    out.push_str("Workspace Profile\n");
183    out.push_str(&format!("Path: {}\n", path.display()));
184    out.push_str(&format!("Mode: {}\n", profile.workspace_mode));
185    out.push_str(&format!(
186        "Primary stack: {}\n",
187        profile.primary_stack.as_deref().unwrap_or("unknown")
188    ));
189    if !profile.stack_signals.is_empty() {
190        out.push_str(&format!(
191            "Stack signals: {}\n",
192            profile.stack_signals.join(", ")
193        ));
194    }
195    if !profile.package_managers.is_empty() {
196        out.push_str(&format!(
197            "Package managers: {}\n",
198            profile.package_managers.join(", ")
199        ));
200    }
201    if let Some(profile_name) = &profile.verify_profile {
202        out.push_str(&format!("Verify profile: {}\n", profile_name));
203    }
204    if let Some(build_hint) = &profile.build_hint {
205        out.push_str(&format!("Build hint: {}\n", build_hint));
206    }
207    if let Some(test_hint) = &profile.test_hint {
208        out.push_str(&format!("Test hint: {}\n", test_hint));
209    }
210    if let Some(contract) = &profile.runtime_contract {
211        out.push_str(&format!("Loop family: {}\n", contract.loop_family));
212        out.push_str(&format!("App kind: {}\n", contract.app_kind));
213        if let Some(framework) = &contract.framework_hint {
214            out.push_str(&format!("Framework hint: {}\n", framework));
215        }
216        if let Some(url) = &contract.local_url_hint {
217            out.push_str(&format!("Local URL hint: {}\n", url));
218        }
219        if !contract.preferred_workflows.is_empty() {
220            out.push_str(&format!(
221                "Preferred workflows: {}\n",
222                contract.preferred_workflows.join(", ")
223            ));
224        }
225        if !contract.delivery_phases.is_empty() {
226            out.push_str(&format!(
227                "Delivery phases: {}\n",
228                contract.delivery_phases.join(" -> ")
229            ));
230        }
231        if !contract.verification_workflows.is_empty() {
232            out.push_str(&format!(
233                "Verification workflows: {}\n",
234                contract.verification_workflows.join(", ")
235            ));
236        }
237        if !contract.quality_gates.is_empty() {
238            out.push_str(&format!(
239                "Quality gates: {}\n",
240                contract.quality_gates.join("; ")
241            ));
242        }
243        if !contract.route_hints.is_empty() {
244            out.push_str(&format!(
245                "Route hints: {}\n",
246                contract.route_hints.join(", ")
247            ));
248        }
249    }
250    if !profile.important_paths.is_empty() {
251        out.push_str(&format!(
252            "Important paths: {}\n",
253            profile.important_paths.join(", ")
254        ));
255    }
256    if !profile.ignored_paths.is_empty() {
257        out.push_str(&format!(
258            "Ignored noise: {}\n",
259            profile.ignored_paths.join(", ")
260        ));
261    }
262    out.push_str(&format!("Summary: {}", profile.summary));
263    out
264}
265
266pub fn detect_workspace_profile(root: &Path) -> WorkspaceProfile {
267    let is_project = looks_like_project_root(root);
268    let workspace_mode = if is_project {
269        "project"
270    } else if root.join(".hematite").join("docs").exists()
271        || root.join(".hematite").join("imports").exists()
272    {
273        // Only fallback to docs_only if we haven't already identified a managed project
274        "docs_only"
275    } else if root.join(".hematite").exists() {
276        // If managed but no markers, treat as general (more tools available)
277        "general"
278    } else {
279        "general"
280    }
281    .to_string();
282
283    let mut stack_signals = BTreeSet::new();
284    let mut package_managers = BTreeSet::new();
285
286    if root.join("Cargo.toml").exists() {
287        stack_signals.insert("rust".to_string());
288        package_managers.insert("cargo".to_string());
289    }
290    if root.join("package.json").exists() {
291        stack_signals.insert("node".to_string());
292        package_managers.insert(detect_node_package_manager(root));
293    }
294    if root.join("pyproject.toml").exists()
295        || root.join("setup.py").exists()
296        || root.join("requirements.txt").exists()
297        || root.join(".venv").is_dir()
298        || root.join("venv").is_dir()
299        || root.join("env").is_dir()
300    {
301        stack_signals.insert("python".to_string());
302        package_managers.insert(detect_python_package_manager(root));
303    }
304    if root.join("go.mod").exists() {
305        stack_signals.insert("go".to_string());
306        package_managers.insert("go".to_string());
307    }
308    if root.join("pom.xml").exists() {
309        stack_signals.insert("java".to_string());
310        package_managers.insert("maven".to_string());
311    }
312    if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
313        stack_signals.insert("java".to_string());
314        package_managers.insert("gradle".to_string());
315    }
316    if root.join("CMakeLists.txt").exists() {
317        stack_signals.insert("cpp".to_string());
318        package_managers.insert("cmake".to_string());
319    }
320    if has_extension_in_dir(root, "sln") || has_extension_in_dir(root, "csproj") {
321        stack_signals.insert("dotnet".to_string());
322        package_managers.insert("dotnet".to_string());
323    }
324    if root.join("index.html").exists()
325        || root.join("style.css").exists()
326        || root.join("script.js").exists()
327    {
328        stack_signals.insert("static-web".to_string());
329    }
330    if root.join(".git").exists() && stack_signals.is_empty() {
331        stack_signals.insert("git".to_string());
332    }
333
334    let primary_stack = stack_signals
335        .iter()
336        .find(|stack| stack.as_str() != "git")
337        .cloned()
338        .or_else(|| stack_signals.iter().next().cloned());
339
340    let important_paths = collect_existing_paths(
341        root,
342        &[
343            "src",
344            "tests",
345            "docs",
346            "installer",
347            "scripts",
348            ".github/workflows",
349            ".hematite/docs",
350            ".hematite/imports",
351        ],
352    );
353    let ignored_paths = collect_existing_paths(
354        root,
355        &[
356            "target",
357            "node_modules",
358            ".venv",
359            "venv",
360            "env",
361            "vendor",
362            "__pycache__",
363            ".git",
364            ".hematite/reports",
365            ".hematite/scratch",
366        ],
367    );
368
369    let verify = load_workspace_verify_config(root);
370    let verify_profile = verify.default_profile.clone();
371    let (build_hint, test_hint) = if let Some(profile_name) = verify_profile.as_deref() {
372        if let Some(profile) = verify.profiles.get(profile_name) {
373            (profile.build.clone(), profile.test.clone())
374        } else {
375            (
376                default_build_hint(root, primary_stack.as_deref()),
377                default_test_hint(root, primary_stack.as_deref()),
378            )
379        }
380    } else {
381        (
382            default_build_hint(root, primary_stack.as_deref()),
383            default_test_hint(root, primary_stack.as_deref()),
384        )
385    };
386    let runtime_contract = detect_runtime_contract(root, &workspace_mode, primary_stack.as_deref());
387
388    let summary = build_summary(
389        &workspace_mode,
390        primary_stack.as_deref(),
391        &important_paths,
392        verify_profile.as_deref(),
393        build_hint.as_deref(),
394        test_hint.as_deref(),
395        Some(&runtime_contract),
396    );
397
398    WorkspaceProfile {
399        workspace_mode,
400        primary_stack,
401        stack_signals: stack_signals.into_iter().collect(),
402        package_managers: package_managers
403            .into_iter()
404            .filter(|entry| !entry.is_empty())
405            .collect(),
406        important_paths,
407        ignored_paths,
408        verify_profile,
409        build_hint,
410        test_hint,
411        runtime_contract: Some(runtime_contract),
412        summary,
413    }
414}
415
416fn looks_like_project_root(root: &Path) -> bool {
417    root.join("Cargo.toml").exists()
418        || root.join("package.json").exists()
419        || root.join("pyproject.toml").exists()
420        || root.join("go.mod").exists()
421        || root.join("setup.py").exists()
422        || root.join("pom.xml").exists()
423        || root.join("build.gradle").exists()
424        || root.join("build.gradle.kts").exists()
425        || root.join("CMakeLists.txt").exists()
426        || root.join("index.html").exists()
427        || root.join("style.css").exists()
428        || root.join("script.js").exists()
429        || root.join("main.py").exists()
430        || root.join("HEMATITE_HANDOFF.md").exists()
431        || root.join(".hematite").join("PLAN.md").exists()
432        || root.join(".hematite").join("plan.md").exists()
433        || root.join(".hematite").join("TASK.md").exists()
434        || root.join(".hematite").join("task.md").exists()
435        || root.join(".hematite").join("settings.json").exists()
436        || root.join(".hematite").join("ACTIVE_EXEC_PLAN").exists()
437        || (root.join(".git").exists() && root.join("src").exists())
438}
439
440fn has_extension_in_dir(root: &Path, ext: &str) -> bool {
441    std::fs::read_dir(root)
442        .ok()
443        .into_iter()
444        .flat_map(|entries| entries.filter_map(|entry| entry.ok()))
445        .any(|entry| {
446            entry
447                .path()
448                .extension()
449                .and_then(|value| value.to_str())
450                .map(|value| value.eq_ignore_ascii_case(ext))
451                .unwrap_or(false)
452        })
453}
454
455fn detect_node_package_manager(root: &Path) -> String {
456    if root.join("pnpm-lock.yaml").exists() {
457        "pnpm".to_string()
458    } else if root.join("yarn.lock").exists() {
459        "yarn".to_string()
460    } else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
461        "bun".to_string()
462    } else {
463        "npm".to_string()
464    }
465}
466
467fn detect_python_package_manager(root: &Path) -> String {
468    let pyproject = root.join("pyproject.toml");
469    if let Ok(content) = std::fs::read_to_string(pyproject) {
470        let lower = content.to_ascii_lowercase();
471        if lower.contains("[tool.uv") {
472            return "uv".to_string();
473        }
474        if lower.contains("[tool.poetry") {
475            return "poetry".to_string();
476        }
477        if lower.contains("[project]") {
478            return "pip/pyproject".to_string();
479        }
480    }
481    "pip".to_string()
482}
483
484fn collect_existing_paths(root: &Path, candidates: &[&str]) -> Vec<String> {
485    candidates
486        .iter()
487        .filter(|candidate| root.join(candidate).exists())
488        .map(|candidate| candidate.replace('\\', "/"))
489        .collect()
490}
491
492fn default_build_hint(root: &Path, primary_stack: Option<&str>) -> Option<String> {
493    match primary_stack {
494        Some("rust") => Some("cargo build".to_string()),
495        Some("node") => {
496            if root.join("package.json").exists() {
497                Some(format!("{} run build", detect_node_package_manager(root)))
498            } else {
499                None
500            }
501        }
502        Some("python") => None,
503        Some("go") => Some("go build ./...".to_string()),
504        Some("java") => {
505            if root.join("pom.xml").exists() {
506                Some("mvn -q -DskipTests package".to_string())
507            } else if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
508                Some("./gradlew build".to_string())
509            } else {
510                None
511            }
512        }
513        Some("cpp") => Some("cmake --build build".to_string()),
514        _ => None,
515    }
516}
517
518fn default_test_hint(root: &Path, primary_stack: Option<&str>) -> Option<String> {
519    match primary_stack {
520        Some("rust") => Some("cargo test".to_string()),
521        Some("node") => Some(format!("{} test", detect_node_package_manager(root))),
522        Some("python") => {
523            if root.join("tests").exists() || root.join("test").exists() {
524                Some("pytest".to_string())
525            } else {
526                None
527            }
528        }
529        Some("go") => Some("go test ./...".to_string()),
530        Some("java") => {
531            if root.join("pom.xml").exists() {
532                Some("mvn test".to_string())
533            } else if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
534                Some("./gradlew test".to_string())
535            } else {
536                None
537            }
538        }
539        _ => None,
540    }
541}
542
543fn detect_runtime_contract(
544    root: &Path,
545    workspace_mode: &str,
546    primary_stack: Option<&str>,
547) -> RuntimeContract {
548    if let Some(stack) = primary_stack {
549        let contract = match stack {
550            "node" => detect_node_runtime_contract(root),
551            "rust" => detect_rust_runtime_contract(root),
552            "python" => detect_python_runtime_contract(root),
553            "static-web" => Some(detect_static_runtime_contract()),
554            _ => None,
555        };
556        if let Some(c) = contract {
557            return c;
558        }
559    }
560
561    if workspace_mode == "docs_only" {
562        return detect_docs_runtime_contract();
563    }
564
565    detect_general_runtime_contract()
566}
567
568fn detect_static_runtime_contract() -> RuntimeContract {
569    RuntimeContract {
570        loop_family: "website".to_string(),
571        app_kind: "static-site".to_string(),
572        framework_hint: Some("vanilla".to_string()),
573        preferred_workflows: vec!["website_status".to_string()],
574        delivery_phases: vec![
575            "design layout and asset structure".to_string(),
576            "implement semantic html".to_string(),
577            "style with vanilla css".to_string(),
578            "validate assets and responsive behavior".to_string(),
579        ],
580        verification_workflows: vec!["build".to_string()],
581        quality_gates: vec![
582            "index.html exists and is valid".to_string(),
583            "all linked assets resolve (no 404s)".to_string(),
584            "responsive on mobile and desktop".to_string(),
585        ],
586        local_url_hint: None,
587        route_hints: vec!["/".to_string()],
588    }
589}
590
591fn detect_docs_runtime_contract() -> RuntimeContract {
592    RuntimeContract {
593        loop_family: "docs".to_string(),
594        app_kind: "technical-documentation".to_string(),
595        framework_hint: Some("markdown".to_string()),
596        preferred_workflows: vec!["inspect_host".to_string()],
597        delivery_phases: vec![
598            "research and outline".to_string(),
599            "draft core content".to_string(),
600            "proofread and verify technical accuracy".to_string(),
601            "check internal links and cross-references".to_string(),
602        ],
603        verification_workflows: vec!["build".to_string()],
604        quality_gates: vec![
605            "adheres to project voice".to_string(),
606            "no placeholders or incomplete sections".to_string(),
607            "all internal file links resolve".to_string(),
608        ],
609        local_url_hint: None,
610        route_hints: vec![],
611    }
612}
613
614fn detect_general_runtime_contract() -> RuntimeContract {
615    RuntimeContract {
616        loop_family: "general".to_string(),
617        app_kind: "workstation-automation".to_string(),
618        framework_hint: None,
619        preferred_workflows: vec!["inspect_host".to_string(), "verify_build".to_string()],
620        delivery_phases: vec![
621            "research and environment discovery".to_string(),
622            "planned surgical implementation".to_string(),
623            "automated verification".to_string(),
624            "completion report".to_string(),
625        ],
626        verification_workflows: vec!["build".to_string()],
627        quality_gates: vec![
628            "implementation satisfies objective".to_string(),
629            "no logic regressions".to_string(),
630            "workspace remains clean and hygienic".to_string(),
631        ],
632        local_url_hint: None,
633        route_hints: vec![],
634    }
635}
636
637fn detect_node_runtime_contract(root: &Path) -> Option<RuntimeContract> {
638    let package = read_package_json(root).ok()?;
639    let scripts = package_scripts(&package);
640    let framework = infer_node_framework(&package);
641    let is_website = looks_like_node_website(root, &scripts, framework.as_deref());
642
643    if is_website {
644        let local_url_hint = infer_website_default_url(framework.as_deref(), &scripts);
645        return Some(RuntimeContract {
646            loop_family: "website".to_string(),
647            app_kind: "website".to_string(),
648            framework_hint: framework.clone(),
649            preferred_workflows: vec![
650                "website_start".to_string(),
651                "website_validate".to_string(),
652                "website_status".to_string(),
653                "website_stop".to_string(),
654            ],
655            delivery_phases: vec![
656                "design routes and boundaries".to_string(),
657                "scaffold feature shell".to_string(),
658                "implement UI and interaction logic".to_string(),
659                "validate routes and assets".to_string(),
660                "update docs and task ledger".to_string(),
661            ],
662            verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
663            quality_gates: vec![
664                "build stays green".to_string(),
665                "critical routes return HTTP 200".to_string(),
666                "linked local assets resolve".to_string(),
667            ],
668            local_url_hint,
669            route_hints: infer_website_route_hints(root),
670        });
671    }
672
673    if looks_like_node_service(&package, &scripts) {
674        return Some(RuntimeContract {
675            loop_family: "service".to_string(),
676            app_kind: "node-service".to_string(),
677            framework_hint: framework,
678            preferred_workflows: vec![
679                "package_script".to_string(),
680                "build".to_string(),
681                "test".to_string(),
682            ],
683            delivery_phases: vec![
684                "define service boundary and inputs".to_string(),
685                "implement handlers and domain logic".to_string(),
686                "wire config and runtime entrypoint".to_string(),
687                "verify build and targeted tests".to_string(),
688                "document operational assumptions".to_string(),
689            ],
690            verification_workflows: vec!["build".to_string()],
691            quality_gates: vec![
692                "build stays green".to_string(),
693                "tests cover changed behavior".to_string(),
694                "config and entrypoint stay explicit".to_string(),
695            ],
696            local_url_hint: None,
697            route_hints: Vec::new(),
698        });
699    }
700
701    None
702}
703
704fn detect_rust_runtime_contract(root: &Path) -> Option<RuntimeContract> {
705    if root.join("src").join("main.rs").exists() {
706        Some(RuntimeContract {
707            loop_family: "cli".to_string(),
708            app_kind: "rust-cli".to_string(),
709            framework_hint: None,
710            preferred_workflows: vec!["build".to_string(), "test".to_string(), "lint".to_string()],
711            delivery_phases: vec![
712                "shape command surface".to_string(),
713                "implement core behavior".to_string(),
714                "tighten errors and output".to_string(),
715                "verify build tests and lint".to_string(),
716                "document usage and follow-up debt".to_string(),
717            ],
718            verification_workflows: vec!["build".to_string()],
719            quality_gates: vec![
720                "build stays green".to_string(),
721                "tests cover command behavior".to_string(),
722                "lint stays clean".to_string(),
723            ],
724            local_url_hint: None,
725            route_hints: Vec::new(),
726        })
727    } else {
728        None
729    }
730}
731
732fn detect_python_runtime_contract(root: &Path) -> Option<RuntimeContract> {
733    let requirements = std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default();
734    let pyproject = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default();
735    let combined = format!(
736        "{}\n{}",
737        requirements.to_ascii_lowercase(),
738        pyproject.to_ascii_lowercase()
739    );
740    if combined.contains("fastapi") || combined.contains("flask") || combined.contains("django") {
741        Some(RuntimeContract {
742            loop_family: "service".to_string(),
743            app_kind: "python-web-service".to_string(),
744            framework_hint: if combined.contains("fastapi") {
745                Some("fastapi".to_string())
746            } else if combined.contains("django") {
747                Some("django".to_string())
748            } else {
749                Some("flask".to_string())
750            },
751            preferred_workflows: vec!["build".to_string(), "test".to_string()],
752            delivery_phases: vec![
753                "define API surface and schemas".to_string(),
754                "implement service logic".to_string(),
755                "wire runtime and config".to_string(),
756                "verify build and tests".to_string(),
757                "document operational assumptions".to_string(),
758            ],
759            verification_workflows: vec!["build".to_string()],
760            quality_gates: vec![
761                "module import/compile pass".to_string(),
762                "tests cover changed behavior".to_string(),
763                "runtime entrypoint remains explicit".to_string(),
764            ],
765            local_url_hint: Some("http://127.0.0.1:8000/".to_string()),
766            route_hints: vec!["/".to_string()],
767        })
768    } else {
769        None
770    }
771}
772
773fn read_package_json(root: &Path) -> Result<Value, String> {
774    let path = root.join("package.json");
775    let raw = std::fs::read_to_string(&path)
776        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
777    serde_json::from_str(&raw).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
778}
779
780fn package_scripts(package: &Value) -> serde_json::Map<String, Value> {
781    package
782        .get("scripts")
783        .and_then(|value| value.as_object())
784        .cloned()
785        .unwrap_or_default()
786}
787
788fn package_script_text(scripts: &serde_json::Map<String, Value>) -> String {
789    scripts
790        .values()
791        .filter_map(|value| value.as_str())
792        .map(|value| value.to_ascii_lowercase())
793        .collect::<Vec<_>>()
794        .join("\n")
795}
796
797fn package_dependency_names(package: &Value) -> BTreeSet<String> {
798    let mut deps = BTreeSet::new();
799    for field in ["dependencies", "devDependencies", "peerDependencies"] {
800        if let Some(map) = package.get(field).and_then(|value| value.as_object()) {
801            for name in map.keys() {
802                deps.insert(name.to_ascii_lowercase());
803            }
804        }
805    }
806    deps
807}
808
809fn infer_node_framework(package: &Value) -> Option<String> {
810    let deps = package_dependency_names(package);
811    let scripts = package_script_text(&package_scripts(package));
812    if deps.contains("next") || scripts.contains("next ") {
813        Some("next".to_string())
814    } else if deps.contains("vite") || scripts.contains("vite") {
815        Some("vite".to_string())
816    } else if deps.contains("astro") || scripts.contains("astro ") {
817        Some("astro".to_string())
818    } else if deps.contains("@angular/core") || scripts.contains("ng serve") {
819        Some("angular".to_string())
820    } else if deps.contains("gatsby") || scripts.contains("gatsby ") {
821        Some("gatsby".to_string())
822    } else if deps.contains("react-scripts") || scripts.contains("react-scripts") {
823        Some("react-scripts".to_string())
824    } else if deps.contains("@sveltejs/kit") || scripts.contains("svelte-kit") {
825        Some("sveltekit".to_string())
826    } else if deps.contains("nuxt") || scripts.contains("nuxt ") {
827        Some("nuxt".to_string())
828    } else if deps.contains("express") {
829        Some("express".to_string())
830    } else {
831        None
832    }
833}
834
835fn looks_like_node_service(package: &Value, scripts: &serde_json::Map<String, Value>) -> bool {
836    let deps = package_dependency_names(package);
837    let script_text = package_script_text(scripts);
838    deps.contains("express")
839        || deps.contains("fastify")
840        || deps.contains("koa")
841        || script_text.contains("node server")
842        || script_text.contains("tsx server")
843        || script_text.contains("nest start")
844}
845
846fn looks_like_node_website(
847    root: &Path,
848    scripts: &serde_json::Map<String, Value>,
849    framework: Option<&str>,
850) -> bool {
851    let script_text = package_script_text(scripts);
852    matches!(
853        framework,
854        Some("vite")
855            | Some("next")
856            | Some("astro")
857            | Some("gatsby")
858            | Some("react-scripts")
859            | Some("sveltekit")
860            | Some("nuxt")
861            | Some("angular")
862    ) || scripts.contains_key("preview")
863        || script_text.contains("vite")
864        || script_text.contains("next ")
865        || script_text.contains("astro ")
866        || script_text.contains("gatsby ")
867        || script_text.contains("react-scripts")
868        || script_text.contains("ng serve")
869        || script_text.contains("nuxt ")
870        || root.join("public").exists()
871        || root.join("static").exists()
872        || root.join("pages").exists()
873        || root.join("src").join("pages").exists()
874        || root.join("app").exists()
875        || root.join("src").join("app").exists()
876}
877
878fn infer_website_default_url(
879    framework: Option<&str>,
880    scripts: &serde_json::Map<String, Value>,
881) -> Option<String> {
882    let uses_preview = scripts.contains_key("preview") && !scripts.contains_key("dev");
883    let port = match framework {
884        Some("vite") | Some("sveltekit") => {
885            if uses_preview {
886                4173
887            } else {
888                5173
889            }
890        }
891        Some("astro") => 4321,
892        Some("gatsby") => 8000,
893        Some("angular") => 4200,
894        Some("next") | Some("react-scripts") | Some("nuxt") => 3000,
895        _ => 3000,
896    };
897    Some(format!("http://127.0.0.1:{}/", port))
898}
899
900fn infer_website_route_hints(root: &Path) -> Vec<String> {
901    let mut routes = BTreeSet::new();
902    routes.insert("/".to_string());
903
904    for public_dir in ["public", "static"] {
905        let dir = root.join(public_dir);
906        if let Ok(entries) = std::fs::read_dir(&dir) {
907            for entry in entries.filter_map(Result::ok) {
908                let path = entry.path();
909                if path.extension().and_then(|value| value.to_str()) == Some("html") {
910                    if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
911                        if stem.eq_ignore_ascii_case("index") {
912                            routes.insert("/".to_string());
913                        } else {
914                            routes.insert(format!("/{}.html", stem));
915                        }
916                    }
917                }
918            }
919        }
920    }
921
922    for pages_dir in ["pages", "src/pages"] {
923        collect_pages_routes(&root.join(pages_dir), &mut routes);
924    }
925    for app_dir in ["app", "src/app"] {
926        collect_app_routes(&root.join(app_dir), &mut routes);
927    }
928
929    routes.into_iter().collect()
930}
931
932fn collect_pages_routes(dir: &Path, routes: &mut BTreeSet<String>) {
933    collect_routes_recursive(dir, dir, routes, false);
934}
935
936fn collect_app_routes(dir: &Path, routes: &mut BTreeSet<String>) {
937    collect_routes_recursive(dir, dir, routes, true);
938}
939
940fn collect_routes_recursive(dir: &Path, base: &Path, routes: &mut BTreeSet<String>, app_dir: bool) {
941    let Ok(entries) = std::fs::read_dir(dir) else {
942        return;
943    };
944    for entry in entries.filter_map(Result::ok) {
945        let path = entry.path();
946        let name = entry.file_name().to_string_lossy().to_string();
947        if path.is_dir() {
948            if name.starts_with('[')
949                || name == "api"
950                || name.starts_with('(')
951                || name.starts_with('@')
952            {
953                continue;
954            }
955            collect_routes_recursive(&path, base, routes, app_dir);
956            continue;
957        }
958
959        let is_page_file = if app_dir {
960            name.starts_with("page.")
961        } else {
962            matches!(
963                path.extension().and_then(|value| value.to_str()),
964                Some("js" | "jsx" | "ts" | "tsx" | "mdx")
965            )
966        };
967        if !is_page_file {
968            continue;
969        }
970        if matches!(
971            name.as_str(),
972            "_app.tsx"
973                | "_app.jsx"
974                | "_document.tsx"
975                | "_document.jsx"
976                | "layout.tsx"
977                | "layout.jsx"
978                | "template.tsx"
979                | "template.jsx"
980                | "error.tsx"
981                | "loading.tsx"
982                | "not-found.tsx"
983        ) {
984            continue;
985        }
986        if let Ok(relative) = path.strip_prefix(base) {
987            let mut segments: Vec<String> = relative
988                .iter()
989                .filter_map(|part| part.to_str().map(|value| value.to_string()))
990                .collect();
991            if app_dir {
992                let _ = segments.pop();
993            } else if let Some(last) = segments.last_mut() {
994                if let Some(stem) = Path::new(last).file_stem().and_then(|value| value.to_str()) {
995                    *last = stem.to_string();
996                }
997            }
998            segments.retain(|segment| {
999                !segment.is_empty() && segment != "index" && !segment.starts_with('[')
1000            });
1001            let route = if segments.is_empty() {
1002                "/".to_string()
1003            } else {
1004                format!("/{}", segments.join("/"))
1005            };
1006            routes.insert(route);
1007        }
1008    }
1009}
1010
1011fn build_summary(
1012    workspace_mode: &str,
1013    primary_stack: Option<&str>,
1014    important_paths: &[String],
1015    verify_profile: Option<&str>,
1016    build_hint: Option<&str>,
1017    test_hint: Option<&str>,
1018    runtime_contract: Option<&RuntimeContract>,
1019) -> String {
1020    let mut parts = Vec::new();
1021    match workspace_mode {
1022        "project" => {
1023            if let Some(stack) = primary_stack {
1024                parts.push(format!("{stack} project workspace"));
1025            } else {
1026                parts.push("project workspace".to_string());
1027            }
1028        }
1029        "docs_only" => parts.push("docs-only workspace".to_string()),
1030        _ => parts.push("general local workspace".to_string()),
1031    }
1032
1033    if !important_paths.is_empty() {
1034        parts.push(format!("key paths: {}", important_paths.join(", ")));
1035    }
1036    if let Some(profile) = verify_profile {
1037        parts.push(format!("verify profile: {}", profile));
1038    } else if let Some(build) = build_hint {
1039        parts.push(format!("suggested build: {}", build));
1040    }
1041    if let Some(test) = test_hint {
1042        parts.push(format!("suggested test: {}", test));
1043    }
1044    if let Some(contract) = runtime_contract {
1045        parts.push(format!(
1046            "control loop: {} {}",
1047            contract.loop_family, contract.app_kind
1048        ));
1049        if !contract.verification_workflows.is_empty() {
1050            parts.push(format!(
1051                "verify via: {}",
1052                contract.verification_workflows.join(" + ")
1053            ));
1054        }
1055        if let Some(url) = contract.local_url_hint.as_deref() {
1056            parts.push(format!("local url: {}", url));
1057        }
1058    }
1059
1060    parts.join(" | ")
1061}
1062
1063fn load_workspace_verify_config(root: &Path) -> crate::agent::config::VerifyProfilesConfig {
1064    let path = if crate::tools::file_ops::is_os_shortcut_directory(root) {
1065        crate::tools::file_ops::hematite_dir().join("settings.json")
1066    } else {
1067        root.join(".hematite").join("settings.json")
1068    };
1069    std::fs::read_to_string(path)
1070        .ok()
1071        .and_then(|raw| serde_json::from_str::<crate::agent::config::HematiteConfig>(&raw).ok())
1072        .map(|config| config.verify)
1073        .unwrap_or_default()
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use std::fs;
1080    use tempfile::tempdir;
1081
1082    #[test]
1083    fn test_detects_static_site_contract() {
1084        let dir = tempdir().unwrap();
1085        fs::write(dir.path().join("index.html"), "<html></html>").unwrap();
1086
1087        let profile = detect_workspace_profile(dir.path());
1088        assert_eq!(profile.workspace_mode, "project");
1089        assert_eq!(profile.primary_stack.as_deref(), Some("static-web"));
1090
1091        let contract = profile
1092            .runtime_contract
1093            .as_ref()
1094            .expect("Contract should exist");
1095        assert_eq!(contract.app_kind, "static-site");
1096        assert!(contract
1097            .delivery_phases
1098            .iter()
1099            .any(|p| p.contains("vanilla css")));
1100    }
1101
1102    #[test]
1103    fn test_detects_docs_only_contract() {
1104        let dir = tempdir().unwrap();
1105        // Mock docs-only mode by creating .hematite/docs
1106        let hem = dir.path().join(".hematite");
1107        fs::create_dir_all(hem.join("docs")).unwrap();
1108
1109        let profile = detect_workspace_profile(dir.path());
1110        assert_eq!(profile.workspace_mode, "docs_only");
1111
1112        let contract = profile
1113            .runtime_contract
1114            .as_ref()
1115            .expect("Contract should exist");
1116        assert_eq!(contract.app_kind, "technical-documentation");
1117    }
1118
1119    #[test]
1120    fn test_managed_workspace_is_not_docs_only() {
1121        let dir = tempdir().unwrap();
1122        // folder has a .hematite folder but no docs yet
1123        fs::create_dir_all(dir.path().join(".hematite")).unwrap();
1124
1125        let profile = detect_workspace_profile(dir.path());
1126        assert_eq!(profile.workspace_mode, "general"); // Managed but unknown stack
1127    }
1128
1129    #[test]
1130    fn test_plan_triggers_project_mode() {
1131        let dir = tempdir().unwrap();
1132        let hem = dir.path().join(".hematite");
1133        fs::create_dir_all(&hem).unwrap();
1134        fs::write(hem.join("PLAN.md"), "# The Plan").unwrap();
1135
1136        let profile = detect_workspace_profile(dir.path());
1137        assert_eq!(profile.workspace_mode, "project");
1138    }
1139}