Skip to main content

hematite/tools/
plan.rs

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
630/// Strip markdown formatting and parenthetical annotations from a bullet path.
631/// e.g. "`src/runtime.rs` (startup greeting)" -> "src/runtime.rs"
632fn 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
659/// Manages a persistent mission plan for the agent in `.hematite/PLAN.md`.
660pub 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
684/// Generates a final walkthrough report for the current session.
685pub 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}