1use crate::tools::file_ops::{hematite_dir, is_project_workspace, workspace_root};
2use serde_json::{json, Value};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7const EXEC_PLANS_DIR: &str = "docs/exec-plans";
8const ACTIVE_EXEC_PLANS_DIR: &str = "active";
9const COMPLETED_EXEC_PLANS_DIR: &str = "completed";
10const ACTIVE_EXEC_PLAN_MARKER: &str = "ACTIVE_EXEC_PLAN";
11
12#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
13pub struct PlanHandoff {
14 pub goal: String,
15 #[serde(default)]
16 pub target_files: Vec<String>,
17 #[serde(default)]
18 pub ordered_steps: Vec<String>,
19 pub verification: String,
20 #[serde(default)]
21 pub risks: Vec<String>,
22 #[serde(default)]
23 pub open_questions: Vec<String>,
24}
25
26impl PlanHandoff {
27 pub fn has_signal(&self) -> bool {
28 !self.goal.trim().is_empty()
29 || !self.target_files.is_empty()
30 || !self.ordered_steps.is_empty()
31 || !self.verification.trim().is_empty()
32 || !self.risks.is_empty()
33 || !self.open_questions.is_empty()
34 }
35
36 pub fn summary_line(&self) -> String {
37 let goal = self.goal.trim();
38 if goal.is_empty() {
39 "Plan ready".to_string()
40 } else if goal.chars().count() > 48 {
41 let truncated: String = goal.chars().take(45).collect();
42 format!("{truncated}...")
43 } else {
44 goal.to_string()
45 }
46 }
47
48 pub fn to_prompt(&self) -> String {
49 let mut out = String::new();
50 if !self.goal.trim().is_empty() {
51 out.push_str(&format!(" - Goal: {}\n", self.goal.trim()));
52 }
53 if !self.target_files.is_empty() {
54 out.push_str(&format!(
55 " - Target Files: {}\n",
56 self.target_files.join(", ")
57 ));
58 }
59 if !self.ordered_steps.is_empty() {
60 out.push_str(" - Ordered Steps:\n");
61 for step in &self.ordered_steps {
62 out.push_str(&format!(" - {}\n", step));
63 }
64 }
65 if !self.verification.trim().is_empty() {
66 out.push_str(&format!(" - Verification: {}\n", self.verification.trim()));
67 }
68 if !self.risks.is_empty() {
69 out.push_str(" - Risks:\n");
70 for risk in &self.risks {
71 out.push_str(&format!(" - {}\n", risk));
72 }
73 }
74 if !self.open_questions.is_empty() {
75 out.push_str(" - Open Questions:\n");
76 for question in &self.open_questions {
77 out.push_str(&format!(" - {}\n", question));
78 }
79 }
80 out
81 }
82
83 pub fn to_markdown(&self) -> String {
84 let mut out = String::new();
85 out.push_str("# Goal\n");
86 out.push_str(self.goal.trim());
87 out.push_str("\n\n# Target Files\n");
88 if self.target_files.is_empty() {
89 out.push_str("- none specified");
90 } else {
91 for path in &self.target_files {
92 out.push_str(&format!("- {path}\n"));
93 }
94 if out.ends_with('\n') {
95 out.pop();
96 }
97 }
98 out.push_str("\n\n# Ordered Steps\n");
99 if self.ordered_steps.is_empty() {
100 out.push_str("1. clarify implementation steps");
101 } else {
102 for (idx, step) in self.ordered_steps.iter().enumerate() {
103 out.push_str(&format!("{}. {}\n", idx + 1, step));
104 }
105 if out.ends_with('\n') {
106 out.pop();
107 }
108 }
109 out.push_str("\n\n# Verification\n");
110 out.push_str(if self.verification.trim().is_empty() {
111 "verify_build(action: \"build\")"
112 } else {
113 self.verification.trim()
114 });
115 out.push_str("\n\n# Risks\n");
116 if self.risks.is_empty() {
117 out.push_str("- none noted");
118 } else {
119 for risk in &self.risks {
120 out.push_str(&format!("- {risk}\n"));
121 }
122 if out.ends_with('\n') {
123 out.pop();
124 }
125 }
126 out.push_str("\n\n# Open Questions\n");
127 if self.open_questions.is_empty() {
128 out.push_str("- none");
129 } else {
130 for question in &self.open_questions {
131 out.push_str(&format!("- {question}\n"));
132 }
133 if out.ends_with('\n') {
134 out.pop();
135 }
136 }
137 out.push('\n');
138 out
139 }
140}
141
142fn plan_path() -> PathBuf {
143 hematite_dir().join("PLAN.md")
144}
145
146fn plan_path_for_root(root: &Path) -> PathBuf {
147 root.join(".hematite").join("PLAN.md")
148}
149
150fn task_path_for_root(root: &Path) -> PathBuf {
151 root.join(".hematite").join("TASK.md")
152}
153
154fn walkthrough_path() -> PathBuf {
155 hematite_dir().join("WALKTHROUGH.md")
156}
157
158fn teleport_resume_marker_path() -> PathBuf {
159 hematite_dir().join("TELEPORT_RESUME")
160}
161
162fn teleport_resume_marker_path_for_root(root: &Path) -> PathBuf {
163 root.join(".hematite").join("TELEPORT_RESUME")
164}
165
166fn exec_plans_root_for_root(root: &Path) -> PathBuf {
167 root.join(EXEC_PLANS_DIR)
168}
169
170fn active_exec_plans_dir_for_root(root: &Path) -> PathBuf {
171 exec_plans_root_for_root(root).join(ACTIVE_EXEC_PLANS_DIR)
172}
173
174fn completed_exec_plans_dir_for_root(root: &Path) -> PathBuf {
175 exec_plans_root_for_root(root).join(COMPLETED_EXEC_PLANS_DIR)
176}
177
178fn active_exec_plan_marker_path_for_root(root: &Path) -> PathBuf {
179 root.join(".hematite").join(ACTIVE_EXEC_PLAN_MARKER)
180}
181
182fn tech_debt_tracker_path_for_root(root: &Path) -> PathBuf {
183 exec_plans_root_for_root(root).join("tech-debt-tracker.md")
184}
185
186fn exec_plans_readme_path_for_root(root: &Path) -> PathBuf {
187 exec_plans_root_for_root(root).join("README.md")
188}
189
190fn active_exec_plan_path_for_root(root: &Path, slug: &str) -> PathBuf {
191 active_exec_plans_dir_for_root(root).join(format!("{slug}.md"))
192}
193
194fn completed_exec_plan_path_for_root(root: &Path, slug: &str) -> PathBuf {
195 completed_exec_plans_dir_for_root(root).join(format!("{slug}.md"))
196}
197
198fn should_sync_current_workspace_exec_plans() -> bool {
199 is_project_workspace()
200}
201
202fn default_exec_plans_readme() -> String {
203 "# Execution Plans\n\n\
204Active plans in this directory are the long-lived system of record for larger multi-step work.\n\n\
205- `active/` holds the current execution plan Hematite is driving.\n\
206- `completed/` holds archived plans with final walkthrough notes.\n\
207- `tech-debt-tracker.md` captures unfinished or follow-up cleanup discovered during execution.\n\n\
208`.hematite/PLAN.md` remains the fast local handoff. Hematite mirrors meaningful plans here so a repository can carry forward intent across sessions, worktrees, and reviewers.\n"
209 .to_string()
210}
211
212fn default_tech_debt_tracker() -> String {
213 "# Tech Debt Tracker\n\n\
214Use this file for cleanup, refactors, and follow-up work that should survive beyond a single interactive session.\n\n\
215Add concrete unchecked items. Prefer specific debt with enough context for a future agent run.\n"
216 .to_string()
217}
218
219fn ensure_exec_plan_layout_for_root(root: &Path) -> Result<(), String> {
220 fs::create_dir_all(active_exec_plans_dir_for_root(root)).map_err(|e| e.to_string())?;
221 fs::create_dir_all(completed_exec_plans_dir_for_root(root)).map_err(|e| e.to_string())?;
222 fs::create_dir_all(root.join(".hematite")).map_err(|e| e.to_string())?;
223
224 let readme_path = exec_plans_readme_path_for_root(root);
225 if !readme_path.exists() {
226 fs::write(&readme_path, default_exec_plans_readme())
227 .map_err(|e| format!("Failed to write exec plan README: {e}"))?;
228 }
229
230 let debt_path = tech_debt_tracker_path_for_root(root);
231 if !debt_path.exists() {
232 fs::write(&debt_path, default_tech_debt_tracker())
233 .map_err(|e| format!("Failed to write tech debt tracker: {e}"))?;
234 }
235
236 Ok(())
237}
238
239fn slugify_fragment(input: &str) -> String {
240 let mut slug = String::new();
241
242 for ch in input.chars() {
243 let mapped = if ch.is_ascii_alphanumeric() {
244 Some(ch.to_ascii_lowercase())
245 } else if ch.is_whitespace() || matches!(ch, '-' | '_' | '/' | '\\' | ':') {
246 Some('-')
247 } else {
248 None
249 };
250
251 match mapped {
252 Some('-') if !slug.is_empty() && !slug.ends_with('-') => {
253 slug.push('-');
254 }
255 Some('-') => {}
256 Some(c) => {
257 slug.push(c);
258 }
259 None => {}
260 }
261 }
262
263 let trimmed = slug.trim_matches('-');
264 if trimmed.is_empty() {
265 "plan".to_string()
266 } else {
267 trimmed.chars().take(48).collect()
268 }
269}
270
271fn fresh_plan_slug(goal: &str) -> String {
272 let stamp = SystemTime::now()
273 .duration_since(UNIX_EPOCH)
274 .unwrap_or_default()
275 .as_secs();
276 format!("{stamp}-{}", slugify_fragment(goal))
277}
278
279fn read_active_plan_slug_for_root(root: &Path) -> Option<String> {
280 let slug = fs::read_to_string(active_exec_plan_marker_path_for_root(root)).ok()?;
281 let trimmed = slug.trim();
282 if trimmed.is_empty() {
283 None
284 } else {
285 Some(trimmed.to_string())
286 }
287}
288
289fn write_active_plan_slug_for_root(root: &Path, slug: &str) -> Result<(), String> {
290 let path = active_exec_plan_marker_path_for_root(root);
291 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
292 fs::write(path, slug).map_err(|e| format!("Failed to write active exec plan marker: {e}"))
293}
294
295fn clear_active_plan_slug_for_root(root: &Path) {
296 let _ = fs::remove_file(active_exec_plan_marker_path_for_root(root));
297}
298
299fn current_or_new_active_plan_slug_for_root(root: &Path, title_hint: &str) -> String {
300 read_active_plan_slug_for_root(root).unwrap_or_else(|| fresh_plan_slug(title_hint))
301}
302
303fn render_structured_execution_plan(plan: &PlanHandoff, slug: &str, status: &str) -> String {
304 let mut out = String::new();
305 out.push_str(&format!("# Execution Plan: {}\n\n", plan.summary_line()));
306 out.push_str(&format!("- Plan ID: `{slug}`\n"));
307 out.push_str(&format!("- Status: {status}\n"));
308 out.push_str("- Source: `.hematite/PLAN.md`\n\n");
309 out.push_str(&plan.to_markdown());
310 out
311}
312
313fn render_blueprint_execution_plan(blueprint: &str, slug: &str, status: &str) -> String {
314 let title = blueprint
315 .lines()
316 .find(|line| !line.trim().is_empty())
317 .map(|line| line.trim().trim_start_matches('#').trim())
318 .filter(|line| !line.is_empty())
319 .unwrap_or("Strategic Blueprint");
320
321 let mut out = String::new();
322 out.push_str(&format!("# Execution Plan: {title}\n\n"));
323 out.push_str(&format!("- Plan ID: `{slug}`\n"));
324 out.push_str(&format!("- Status: {status}\n"));
325 out.push_str("- Source: `.hematite/PLAN.md`\n\n");
326 out.push_str("## Blueprint\n");
327 out.push_str(blueprint.trim());
328 out.push('\n');
329 out
330}
331
332fn sync_structured_execution_plan_for_root(
333 root: &Path,
334 plan: &PlanHandoff,
335) -> Result<PathBuf, String> {
336 ensure_exec_plan_layout_for_root(root)?;
337 let slug = current_or_new_active_plan_slug_for_root(root, &plan.summary_line());
338 let path = active_exec_plan_path_for_root(root, &slug);
339 fs::write(
340 &path,
341 render_structured_execution_plan(plan, &slug, "active"),
342 )
343 .map_err(|e| format!("Failed to write active execution plan: {e}"))?;
344 write_active_plan_slug_for_root(root, &slug)?;
345 Ok(path)
346}
347
348fn sync_blueprint_execution_plan_for_root(root: &Path, blueprint: &str) -> Result<PathBuf, String> {
349 ensure_exec_plan_layout_for_root(root)?;
350 let title_hint = parse_plan_handoff(blueprint)
351 .map(|plan| plan.summary_line())
352 .unwrap_or_else(|| {
353 blueprint
354 .lines()
355 .find(|line| !line.trim().is_empty())
356 .map(|line| line.trim().to_string())
357 .unwrap_or_else(|| "strategic-blueprint".to_string())
358 });
359 let slug = current_or_new_active_plan_slug_for_root(root, &title_hint);
360 let path = active_exec_plan_path_for_root(root, &slug);
361 fs::write(
362 &path,
363 render_blueprint_execution_plan(blueprint, &slug, "active"),
364 )
365 .map_err(|e| format!("Failed to write active execution plan: {e}"))?;
366 write_active_plan_slug_for_root(root, &slug)?;
367 Ok(path)
368}
369
370pub fn sync_plan_blueprint_for_path(plan_file: &Path, blueprint: &str) -> Result<PathBuf, String> {
371 let Some(dir) = plan_file.parent() else {
372 return Err("PLAN.md path has no parent directory".to_string());
373 };
374 if dir.file_name().and_then(|s| s.to_str()) != Some(".hematite") {
375 return Err("PLAN.md sync requires a .hematite parent directory".to_string());
376 }
377 let Some(root) = dir.parent() else {
378 return Err("PLAN.md sync requires a project root above .hematite".to_string());
379 };
380 sync_blueprint_execution_plan_for_root(root, blueprint)
381}
382
383fn unchecked_task_items_for_root(root: &Path) -> Vec<String> {
384 let Ok(content) = fs::read_to_string(task_path_for_root(root)) else {
385 return Vec::new();
386 };
387
388 content
389 .lines()
390 .filter_map(|line| {
391 let trimmed = line.trim();
392 let stripped = trimmed
393 .strip_prefix("- [ ] ")
394 .or_else(|| trimmed.strip_prefix("* [ ] "))
395 .or_else(|| trimmed.strip_prefix("+ [ ] "))?;
396 if stripped.trim().is_empty() {
397 None
398 } else {
399 Some(stripped.trim().to_string())
400 }
401 })
402 .collect()
403}
404
405fn append_unchecked_tasks_to_tech_debt_tracker(
406 root: &Path,
407 slug: &str,
408 unchecked_tasks: &[String],
409) -> Result<(), String> {
410 if unchecked_tasks.is_empty() {
411 return Ok(());
412 }
413
414 ensure_exec_plan_layout_for_root(root)?;
415 let debt_path = tech_debt_tracker_path_for_root(root);
416 let mut content =
417 fs::read_to_string(&debt_path).unwrap_or_else(|_| default_tech_debt_tracker());
418 if !content.ends_with('\n') {
419 content.push('\n');
420 }
421 let stamp = SystemTime::now()
422 .duration_since(UNIX_EPOCH)
423 .unwrap_or_default()
424 .as_secs();
425 content.push_str(&format!("\n## Carry Forward from `{slug}` ({stamp})\n"));
426 for task in unchecked_tasks {
427 content.push_str(&format!("- [ ] {task}\n"));
428 }
429
430 fs::write(&debt_path, content).map_err(|e| format!("Failed to update tech debt tracker: {e}"))
431}
432
433fn archive_active_execution_plan_for_root(
434 root: &Path,
435 summary: &str,
436) -> Result<Option<PathBuf>, String> {
437 let Some(slug) = read_active_plan_slug_for_root(root) else {
438 return Ok(None);
439 };
440
441 let active_path = active_exec_plan_path_for_root(root, &slug);
442 if !active_path.exists() {
443 clear_active_plan_slug_for_root(root);
444 return Ok(None);
445 }
446
447 ensure_exec_plan_layout_for_root(root)?;
448
449 let active_content = fs::read_to_string(&active_path)
450 .map_err(|e| format!("Failed to read active execution plan: {e}"))?;
451 let mut archived = if active_content.contains("- Status: active") {
452 active_content.replacen("- Status: active", "- Status: completed", 1)
453 } else {
454 active_content
455 };
456 archived.push_str("\n## Walkthrough\n");
457 archived.push_str(summary.trim());
458 archived.push('\n');
459
460 let unchecked_tasks = unchecked_task_items_for_root(root);
461 if !unchecked_tasks.is_empty() {
462 archived.push_str("\n## Carry Forward\n");
463 for task in &unchecked_tasks {
464 archived.push_str(&format!("- [ ] {task}\n"));
465 }
466 }
467
468 let completed_path = completed_exec_plan_path_for_root(root, &slug);
469 fs::write(&completed_path, archived)
470 .map_err(|e| format!("Failed to write completed execution plan: {e}"))?;
471 let _ = fs::remove_file(&active_path);
472 clear_active_plan_slug_for_root(root);
473 append_unchecked_tasks_to_tech_debt_tracker(root, &slug, &unchecked_tasks)?;
474 Ok(Some(completed_path))
475}
476
477pub fn save_plan_handoff(plan: &PlanHandoff) -> Result<(), String> {
478 let path = plan_path();
479 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
480 fs::write(&path, plan.to_markdown()).map_err(|e| format!("Failed to write plan: {e}"))?;
481
482 if should_sync_current_workspace_exec_plans() {
483 let root = workspace_root();
484 let _ = sync_structured_execution_plan_for_root(&root, plan);
485 }
486
487 Ok(())
488}
489
490pub fn save_plan_handoff_for_root(root: &Path, plan: &PlanHandoff) -> Result<(), String> {
491 let path = plan_path_for_root(root);
492 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
493 fs::write(&path, plan.to_markdown()).map_err(|e| format!("Failed to write plan: {e}"))?;
494 seed_plan_support_files_for_root(root, plan)?;
495 let _ = sync_structured_execution_plan_for_root(root, plan);
496 Ok(())
497}
498
499pub fn load_plan_handoff() -> Option<PlanHandoff> {
500 let path = plan_path();
501 let content = fs::read_to_string(path).ok()?;
502 let plan = parse_plan_handoff(&content)?;
503 let _ = seed_plan_support_files_for_root(&workspace_root(), &plan);
504 Some(plan)
505}
506
507pub fn write_teleport_resume_marker_for_root(root: &Path) -> Result<(), String> {
508 let path = teleport_resume_marker_path_for_root(root);
509 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
510 fs::write(&path, b"implement-plan").map_err(|e| format!("Failed to write marker: {e}"))
511}
512
513pub fn consume_teleport_resume_marker() -> bool {
514 let path = teleport_resume_marker_path();
515 if !path.exists() {
516 return false;
517 }
518 let _ = fs::remove_file(&path);
519 true
520}
521
522pub fn parse_plan_handoff(input: &str) -> Option<PlanHandoff> {
523 let sections = collect_sections(input);
524 let goal = sections
525 .get("goal")
526 .map(|s| s.trim().to_string())
527 .unwrap_or_default();
528 let target_files = parse_bullets(
529 sections
530 .get("target files")
531 .map(String::as_str)
532 .unwrap_or(""),
533 );
534 let ordered_steps = parse_ordered(
535 sections
536 .get("ordered steps")
537 .map(String::as_str)
538 .unwrap_or(""),
539 );
540 let verification = sections
541 .get("verification")
542 .map(|s| s.trim().to_string())
543 .unwrap_or_default();
544 let risks = parse_bullets(sections.get("risks").map(String::as_str).unwrap_or(""));
545 let open_questions = parse_bullets(
546 sections
547 .get("open questions")
548 .map(String::as_str)
549 .unwrap_or(""),
550 );
551
552 let plan = PlanHandoff {
553 goal,
554 target_files,
555 ordered_steps,
556 verification,
557 risks,
558 open_questions,
559 };
560 if plan.has_signal() && !plan.goal.trim().is_empty() && !plan.ordered_steps.is_empty() {
561 Some(plan)
562 } else {
563 None
564 }
565}
566
567fn collect_sections(input: &str) -> std::collections::BTreeMap<String, String> {
568 let mut sections = std::collections::BTreeMap::new();
569 let mut current: Option<String> = None;
570 let mut buf = String::new();
571
572 for line in input.lines() {
573 let trimmed = line.trim();
574 if let Some(name) = normalize_heading(trimmed) {
575 if let Some(prev) = current.replace(name) {
576 sections.insert(prev, buf.trim().to_string());
577 buf.clear();
578 }
579 continue;
580 }
581 if current.is_some() {
582 buf.push_str(line);
583 buf.push('\n');
584 }
585 }
586
587 if let Some(prev) = current {
588 sections.insert(prev, buf.trim().to_string());
589 }
590
591 sections
592}
593
594fn normalize_heading(line: &str) -> Option<String> {
595 let heading = line
596 .trim_start_matches('#')
597 .trim()
598 .trim_end_matches(':')
599 .trim();
600 match heading.to_ascii_lowercase().as_str() {
601 "goal" => Some("goal".to_string()),
602 "target files" => Some("target files".to_string()),
603 "ordered steps" => Some("ordered steps".to_string()),
604 "verification" => Some("verification".to_string()),
605 "risks" => Some("risks".to_string()),
606 "open questions" => Some("open questions".to_string()),
607 _ => None,
608 }
609}
610
611fn parse_bullets(section: &str) -> Vec<String> {
612 section
613 .lines()
614 .filter_map(|line| {
615 let trimmed = line.trim();
616 let stripped = trimmed
617 .strip_prefix("- ")
618 .or_else(|| trimmed.strip_prefix("* "))
619 .map(str::trim)?;
620 if stripped.is_empty()
621 || stripped.eq_ignore_ascii_case("none")
622 || stripped.eq_ignore_ascii_case("none specified")
623 {
624 None
625 } else {
626 Some(clean_bullet_path(stripped))
627 }
628 })
629 .filter(|s| !s.is_empty())
630 .collect()
631}
632
633fn default_task_ledger_for_plan(plan: &PlanHandoff) -> String {
634 let mut content = String::from("# Task Ledger\n\n");
635 if plan.ordered_steps.is_empty() {
636 content.push_str("- [ ] Clarify the next implementation step\n");
637 } else {
638 for step in &plan.ordered_steps {
639 content.push_str("- [ ] ");
640 content.push_str(step.trim());
641 content.push('\n');
642 }
643 }
644 content
645}
646
647fn seed_plan_support_files_for_root(root: &Path, plan: &PlanHandoff) -> Result<(), String> {
648 let task_path = task_path_for_root(root);
649 if !task_path.exists()
650 || fs::read_to_string(&task_path)
651 .map(|content| content.trim().is_empty())
652 .unwrap_or(true)
653 {
654 fs::write(&task_path, default_task_ledger_for_plan(plan))
655 .map_err(|e| format!("Failed to seed task ledger: {e}"))?;
656 }
657
658 let walkthrough_path = root.join(".hematite").join("WALKTHROUGH.md");
659 if !walkthrough_path.exists() {
660 fs::write(&walkthrough_path, "")
661 .map_err(|e| format!("Failed to seed walkthrough file: {e}"))?;
662 }
663
664 Ok(())
665}
666
667fn clean_bullet_path(raw: &str) -> String {
670 let no_backticks = raw.replace('`', "");
671 let clean = if let Some(idx) = no_backticks.find(" (") {
672 no_backticks[..idx].trim()
673 } else {
674 no_backticks.trim()
675 };
676 clean.to_string()
677}
678
679fn parse_ordered(section: &str) -> Vec<String> {
680 let mut out = Vec::new();
681 for line in section.lines() {
682 let trimmed = line.trim();
683 let Some(dot_idx) = trimmed.find(". ") else {
684 continue;
685 };
686 if trimmed[..dot_idx].chars().all(|c| c.is_ascii_digit()) {
687 let step = trimmed[dot_idx + 2..].trim();
688 if !step.is_empty() {
689 out.push(step.to_string());
690 }
691 }
692 }
693 out
694}
695
696pub async fn maintain_plan(args: &Value) -> Result<String, String> {
698 let blueprint = args
699 .get("blueprint")
700 .and_then(|v| v.as_str())
701 .ok_or("maintain_plan: 'blueprint' (markdown text) required")?;
702 let plan_path = plan_path();
703
704 fs::create_dir_all(plan_path.parent().unwrap()).map_err(|e| e.to_string())?;
705 fs::write(&plan_path, blueprint).map_err(|e| format!("Failed to write plan: {e}"))?;
706
707 let mut detail = format!(
708 "Strategic Blueprint updated in .hematite/PLAN.md ({} bytes)",
709 blueprint.len()
710 );
711 if should_sync_current_workspace_exec_plans() {
712 let root = workspace_root();
713 if let Ok(path) = sync_blueprint_execution_plan_for_root(&root, blueprint) {
714 detail.push_str(&format!("\nMirrored to {}", path.display()));
715 }
716 }
717
718 Ok(detail)
719}
720
721pub async fn generate_walkthrough(args: &Value) -> Result<String, String> {
723 let summary = args
724 .get("summary")
725 .and_then(|v| v.as_str())
726 .ok_or("generate_walkthrough: 'summary' required")?;
727 let path = walkthrough_path();
728
729 fs::write(&path, summary).map_err(|e| format!("Failed to save walkthrough: {e}"))?;
730
731 let mut detail =
732 "Walkthrough report saved to .hematite/WALKTHROUGH.md. Session complete!".to_string();
733 if should_sync_current_workspace_exec_plans() {
734 let root = workspace_root();
735 if let Ok(Some(archived)) = archive_active_execution_plan_for_root(&root, summary) {
736 detail.push_str(&format!(
737 "\nArchived active execution plan to {}",
738 archived.display()
739 ));
740 }
741 }
742
743 Ok(detail)
744}
745
746pub fn get_plan_params() -> Value {
747 json!({
748 "type": "object",
749 "properties": {
750 "blueprint": {
751 "type": "string",
752 "description": "The full markdown content of the strategic blueprint."
753 }
754 },
755 "required": ["blueprint"]
756 })
757}
758
759pub fn get_walkthrough_params() -> Value {
760 json!({
761 "type": "object",
762 "properties": {
763 "summary": {
764 "type": "string",
765 "description": "The full markdown summary of accomplishments."
766 }
767 },
768 "required": ["summary"]
769 })
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn slugify_fragment_cleans_goal_text() {
778 assert_eq!(
779 slugify_fragment("Build Website: Landing Page / Hero Polish!"),
780 "build-website-landing-page-hero-polish"
781 );
782 assert_eq!(slugify_fragment("###"), "plan");
783 }
784
785 #[test]
786 fn sync_structured_execution_plan_writes_active_doc_and_marker() {
787 let temp = tempfile::tempdir().unwrap();
788 let root = temp.path();
789 let plan = PlanHandoff {
790 goal: "Ship the marketing landing page".to_string(),
791 target_files: vec!["index.html".to_string(), "style.css".to_string()],
792 ordered_steps: vec!["Build the hero".to_string()],
793 verification: "Open index.html".to_string(),
794 risks: vec!["Avoid endless polish".to_string()],
795 open_questions: vec![],
796 };
797
798 let path = sync_structured_execution_plan_for_root(root, &plan).unwrap();
799 let written = fs::read_to_string(&path).unwrap();
800 let slug = fs::read_to_string(active_exec_plan_marker_path_for_root(root))
801 .unwrap()
802 .trim()
803 .to_string();
804
805 assert!(path.starts_with(active_exec_plans_dir_for_root(root)));
806 assert!(written.contains("Status: active"));
807 assert!(written.contains("Ship the marketing landing page"));
808 assert!(!slug.is_empty());
809 assert!(exec_plans_readme_path_for_root(root).exists());
810 assert!(tech_debt_tracker_path_for_root(root).exists());
811 }
812
813 #[test]
814 fn archive_active_execution_plan_moves_plan_and_captures_unchecked_tasks() {
815 let temp = tempfile::tempdir().unwrap();
816 let root = temp.path();
817 let plan = PlanHandoff {
818 goal: "Refine the docs".to_string(),
819 target_files: vec!["README.md".to_string()],
820 ordered_steps: vec!["Update docs".to_string()],
821 verification: "Read the docs".to_string(),
822 risks: vec![],
823 open_questions: vec![],
824 };
825 let active = sync_structured_execution_plan_for_root(root, &plan).unwrap();
826 fs::create_dir_all(root.join(".hematite")).unwrap();
827 fs::write(
828 task_path_for_root(root),
829 "- [x] Update docs\n- [ ] Add reliability notes\n",
830 )
831 .unwrap();
832
833 let archived = archive_active_execution_plan_for_root(root, "Docs walkthrough complete.")
834 .unwrap()
835 .unwrap();
836 let archived_content = fs::read_to_string(&archived).unwrap();
837 let tracker = fs::read_to_string(tech_debt_tracker_path_for_root(root)).unwrap();
838
839 assert!(!active.exists());
840 assert!(archived.exists());
841 assert!(archived_content.contains("Status: completed"));
842 assert!(archived_content.contains("Docs walkthrough complete."));
843 assert!(archived_content.contains("Add reliability notes"));
844 assert!(tracker.contains("Add reliability notes"));
845 assert!(read_active_plan_slug_for_root(root).is_none());
846 }
847
848 #[test]
849 fn save_plan_handoff_for_root_seeds_task_and_walkthrough_files() {
850 let temp = tempfile::tempdir().unwrap();
851 let root = temp.path();
852 let plan = PlanHandoff {
853 goal: "Document the findings".to_string(),
854 target_files: vec!["index.html".to_string()],
855 ordered_steps: vec![
856 "Use `research_web` first to gather context.".to_string(),
857 "Write the single index.html deliverable.".to_string(),
858 ],
859 verification: "Open index.html".to_string(),
860 risks: vec![],
861 open_questions: vec![],
862 };
863
864 save_plan_handoff_for_root(root, &plan).unwrap();
865
866 let task = std::fs::read_to_string(task_path_for_root(root)).unwrap();
867 let walkthrough =
868 std::fs::read_to_string(root.join(".hematite").join("WALKTHROUGH.md")).unwrap();
869 let written_plan = std::fs::read_to_string(plan_path_for_root(root)).unwrap();
870 let parsed = parse_plan_handoff(&written_plan).unwrap();
871
872 assert!(task.contains("Use `research_web` first to gather context."));
873 assert!(task.contains("Write the single index.html deliverable."));
874 assert!(walkthrough.is_empty());
875 assert_eq!(parsed.target_files, vec!["index.html".to_string()]);
876 }
877}