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 let _ = sync_structured_execution_plan_for_root(root, plan);
495 Ok(())
496}
497
498pub fn load_plan_handoff() -> Option<PlanHandoff> {
499 let path = plan_path();
500 let content = fs::read_to_string(path).ok()?;
501 parse_plan_handoff(&content)
502}
503
504pub fn write_teleport_resume_marker_for_root(root: &Path) -> Result<(), String> {
505 let path = teleport_resume_marker_path_for_root(root);
506 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
507 fs::write(&path, b"implement-plan").map_err(|e| format!("Failed to write marker: {e}"))
508}
509
510pub fn consume_teleport_resume_marker() -> bool {
511 let path = teleport_resume_marker_path();
512 if !path.exists() {
513 return false;
514 }
515 let _ = fs::remove_file(&path);
516 true
517}
518
519pub fn parse_plan_handoff(input: &str) -> Option<PlanHandoff> {
520 let sections = collect_sections(input);
521 let goal = sections
522 .get("goal")
523 .map(|s| s.trim().to_string())
524 .unwrap_or_default();
525 let target_files = parse_bullets(
526 sections
527 .get("target files")
528 .map(String::as_str)
529 .unwrap_or(""),
530 );
531 let ordered_steps = parse_ordered(
532 sections
533 .get("ordered steps")
534 .map(String::as_str)
535 .unwrap_or(""),
536 );
537 let verification = sections
538 .get("verification")
539 .map(|s| s.trim().to_string())
540 .unwrap_or_default();
541 let risks = parse_bullets(sections.get("risks").map(String::as_str).unwrap_or(""));
542 let open_questions = parse_bullets(
543 sections
544 .get("open questions")
545 .map(String::as_str)
546 .unwrap_or(""),
547 );
548
549 let plan = PlanHandoff {
550 goal,
551 target_files,
552 ordered_steps,
553 verification,
554 risks,
555 open_questions,
556 };
557 if plan.has_signal() && !plan.goal.trim().is_empty() && !plan.ordered_steps.is_empty() {
558 Some(plan)
559 } else {
560 None
561 }
562}
563
564fn collect_sections(input: &str) -> std::collections::BTreeMap<String, String> {
565 let mut sections = std::collections::BTreeMap::new();
566 let mut current: Option<String> = None;
567 let mut buf = String::new();
568
569 for line in input.lines() {
570 let trimmed = line.trim();
571 if let Some(name) = normalize_heading(trimmed) {
572 if let Some(prev) = current.replace(name) {
573 sections.insert(prev, buf.trim().to_string());
574 buf.clear();
575 }
576 continue;
577 }
578 if current.is_some() {
579 buf.push_str(line);
580 buf.push('\n');
581 }
582 }
583
584 if let Some(prev) = current {
585 sections.insert(prev, buf.trim().to_string());
586 }
587
588 sections
589}
590
591fn normalize_heading(line: &str) -> Option<String> {
592 let heading = line
593 .trim_start_matches('#')
594 .trim()
595 .trim_end_matches(':')
596 .trim();
597 match heading.to_ascii_lowercase().as_str() {
598 "goal" => Some("goal".to_string()),
599 "target files" => Some("target files".to_string()),
600 "ordered steps" => Some("ordered steps".to_string()),
601 "verification" => Some("verification".to_string()),
602 "risks" => Some("risks".to_string()),
603 "open questions" => Some("open questions".to_string()),
604 _ => None,
605 }
606}
607
608fn parse_bullets(section: &str) -> Vec<String> {
609 section
610 .lines()
611 .filter_map(|line| {
612 let trimmed = line.trim();
613 let stripped = trimmed
614 .strip_prefix("- ")
615 .or_else(|| trimmed.strip_prefix("* "))
616 .map(str::trim)?;
617 if stripped.is_empty()
618 || stripped.eq_ignore_ascii_case("none")
619 || stripped.eq_ignore_ascii_case("none specified")
620 {
621 None
622 } else {
623 Some(clean_bullet_path(stripped))
624 }
625 })
626 .filter(|s| !s.is_empty())
627 .collect()
628}
629
630fn clean_bullet_path(raw: &str) -> String {
633 let no_backticks = raw.replace('`', "");
634 let clean = if let Some(idx) = no_backticks.find(" (") {
635 no_backticks[..idx].trim()
636 } else {
637 no_backticks.trim()
638 };
639 clean.to_string()
640}
641
642fn parse_ordered(section: &str) -> Vec<String> {
643 let mut out = Vec::new();
644 for line in section.lines() {
645 let trimmed = line.trim();
646 let Some(dot_idx) = trimmed.find(". ") else {
647 continue;
648 };
649 if trimmed[..dot_idx].chars().all(|c| c.is_ascii_digit()) {
650 let step = trimmed[dot_idx + 2..].trim();
651 if !step.is_empty() {
652 out.push(step.to_string());
653 }
654 }
655 }
656 out
657}
658
659pub async fn maintain_plan(args: &Value) -> Result<String, String> {
661 let blueprint = args
662 .get("blueprint")
663 .and_then(|v| v.as_str())
664 .ok_or("maintain_plan: 'blueprint' (markdown text) required")?;
665 let plan_path = plan_path();
666
667 fs::create_dir_all(plan_path.parent().unwrap()).map_err(|e| e.to_string())?;
668 fs::write(&plan_path, blueprint).map_err(|e| format!("Failed to write plan: {e}"))?;
669
670 let mut detail = format!(
671 "Strategic Blueprint updated in .hematite/PLAN.md ({} bytes)",
672 blueprint.len()
673 );
674 if should_sync_current_workspace_exec_plans() {
675 let root = workspace_root();
676 if let Ok(path) = sync_blueprint_execution_plan_for_root(&root, blueprint) {
677 detail.push_str(&format!("\nMirrored to {}", path.display()));
678 }
679 }
680
681 Ok(detail)
682}
683
684pub async fn generate_walkthrough(args: &Value) -> Result<String, String> {
686 let summary = args
687 .get("summary")
688 .and_then(|v| v.as_str())
689 .ok_or("generate_walkthrough: 'summary' required")?;
690 let path = walkthrough_path();
691
692 fs::write(&path, summary).map_err(|e| format!("Failed to save walkthrough: {e}"))?;
693
694 let mut detail =
695 "Walkthrough report saved to .hematite/WALKTHROUGH.md. Session complete!".to_string();
696 if should_sync_current_workspace_exec_plans() {
697 let root = workspace_root();
698 if let Ok(Some(archived)) = archive_active_execution_plan_for_root(&root, summary) {
699 detail.push_str(&format!(
700 "\nArchived active execution plan to {}",
701 archived.display()
702 ));
703 }
704 }
705
706 Ok(detail)
707}
708
709pub fn get_plan_params() -> Value {
710 json!({
711 "type": "object",
712 "properties": {
713 "blueprint": {
714 "type": "string",
715 "description": "The full markdown content of the strategic blueprint."
716 }
717 },
718 "required": ["blueprint"]
719 })
720}
721
722pub fn get_walkthrough_params() -> Value {
723 json!({
724 "type": "object",
725 "properties": {
726 "summary": {
727 "type": "string",
728 "description": "The full markdown summary of accomplishments."
729 }
730 },
731 "required": ["summary"]
732 })
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn slugify_fragment_cleans_goal_text() {
741 assert_eq!(
742 slugify_fragment("Build Website: Landing Page / Hero Polish!"),
743 "build-website-landing-page-hero-polish"
744 );
745 assert_eq!(slugify_fragment("###"), "plan");
746 }
747
748 #[test]
749 fn sync_structured_execution_plan_writes_active_doc_and_marker() {
750 let temp = tempfile::tempdir().unwrap();
751 let root = temp.path();
752 let plan = PlanHandoff {
753 goal: "Ship the marketing landing page".to_string(),
754 target_files: vec!["index.html".to_string(), "style.css".to_string()],
755 ordered_steps: vec!["Build the hero".to_string()],
756 verification: "Open index.html".to_string(),
757 risks: vec!["Avoid endless polish".to_string()],
758 open_questions: vec![],
759 };
760
761 let path = sync_structured_execution_plan_for_root(root, &plan).unwrap();
762 let written = fs::read_to_string(&path).unwrap();
763 let slug = fs::read_to_string(active_exec_plan_marker_path_for_root(root))
764 .unwrap()
765 .trim()
766 .to_string();
767
768 assert!(path.starts_with(active_exec_plans_dir_for_root(root)));
769 assert!(written.contains("Status: active"));
770 assert!(written.contains("Ship the marketing landing page"));
771 assert!(!slug.is_empty());
772 assert!(exec_plans_readme_path_for_root(root).exists());
773 assert!(tech_debt_tracker_path_for_root(root).exists());
774 }
775
776 #[test]
777 fn archive_active_execution_plan_moves_plan_and_captures_unchecked_tasks() {
778 let temp = tempfile::tempdir().unwrap();
779 let root = temp.path();
780 let plan = PlanHandoff {
781 goal: "Refine the docs".to_string(),
782 target_files: vec!["README.md".to_string()],
783 ordered_steps: vec!["Update docs".to_string()],
784 verification: "Read the docs".to_string(),
785 risks: vec![],
786 open_questions: vec![],
787 };
788 let active = sync_structured_execution_plan_for_root(root, &plan).unwrap();
789 fs::create_dir_all(root.join(".hematite")).unwrap();
790 fs::write(
791 task_path_for_root(root),
792 "- [x] Update docs\n- [ ] Add reliability notes\n",
793 )
794 .unwrap();
795
796 let archived = archive_active_execution_plan_for_root(root, "Docs walkthrough complete.")
797 .unwrap()
798 .unwrap();
799 let archived_content = fs::read_to_string(&archived).unwrap();
800 let tracker = fs::read_to_string(tech_debt_tracker_path_for_root(root)).unwrap();
801
802 assert!(!active.exists());
803 assert!(archived.exists());
804 assert!(archived_content.contains("Status: completed"));
805 assert!(archived_content.contains("Docs walkthrough complete."));
806 assert!(archived_content.contains("Add reliability notes"));
807 assert!(tracker.contains("Add reliability notes"));
808 assert!(read_active_plan_slug_for_root(root).is_none());
809 }
810}