Skip to main content

hematite/agent/
workspace_profile.rs

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