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    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
667/// Strip markdown formatting and parenthetical annotations from a bullet path.
668/// e.g. "`src/runtime.rs` (startup greeting)" -> "src/runtime.rs"
669fn 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
696/// Manages a persistent mission plan for the agent in `.hematite/PLAN.md`.
697pub 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
721/// Generates a final walkthrough report for the current session.
722pub 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}