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