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