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 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 "docs_only"
270 } else if root.join(".hematite").exists() {
271 "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 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 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"); }
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}