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