Skip to main content

hematite/tools/
plan.rs

1use crate::tools::file_ops::workspace_root;
2use serde_json::{json, Value};
3use std::fs;
4
5#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
6pub struct PlanHandoff {
7    pub goal: String,
8    #[serde(default)]
9    pub target_files: Vec<String>,
10    #[serde(default)]
11    pub ordered_steps: Vec<String>,
12    pub verification: String,
13    #[serde(default)]
14    pub risks: Vec<String>,
15    #[serde(default)]
16    pub open_questions: Vec<String>,
17}
18
19impl PlanHandoff {
20    pub fn has_signal(&self) -> bool {
21        !self.goal.trim().is_empty()
22            || !self.target_files.is_empty()
23            || !self.ordered_steps.is_empty()
24            || !self.verification.trim().is_empty()
25            || !self.risks.is_empty()
26            || !self.open_questions.is_empty()
27    }
28
29    pub fn summary_line(&self) -> String {
30        let goal = self.goal.trim();
31        if goal.is_empty() {
32            "Plan ready".to_string()
33        } else if goal.chars().count() > 48 {
34            let truncated: String = goal.chars().take(45).collect();
35            format!("{truncated}...")
36        } else {
37            goal.to_string()
38        }
39    }
40
41    pub fn to_prompt(&self) -> String {
42        let mut out = String::new();
43        if !self.goal.trim().is_empty() {
44            out.push_str(&format!("  - Goal: {}\n", self.goal.trim()));
45        }
46        if !self.target_files.is_empty() {
47            out.push_str(&format!(
48                "  - Target Files: {}\n",
49                self.target_files.join(", ")
50            ));
51        }
52        if !self.ordered_steps.is_empty() {
53            out.push_str("  - Ordered Steps:\n");
54            for step in &self.ordered_steps {
55                out.push_str(&format!("    - {}\n", step));
56            }
57        }
58        if !self.verification.trim().is_empty() {
59            out.push_str(&format!("  - Verification: {}\n", self.verification.trim()));
60        }
61        if !self.risks.is_empty() {
62            out.push_str("  - Risks:\n");
63            for risk in &self.risks {
64                out.push_str(&format!("    - {}\n", risk));
65            }
66        }
67        if !self.open_questions.is_empty() {
68            out.push_str("  - Open Questions:\n");
69            for question in &self.open_questions {
70                out.push_str(&format!("    - {}\n", question));
71            }
72        }
73        out
74    }
75
76    pub fn to_markdown(&self) -> String {
77        let mut out = String::new();
78        out.push_str("# Goal\n");
79        out.push_str(self.goal.trim());
80        out.push_str("\n\n# Target Files\n");
81        if self.target_files.is_empty() {
82            out.push_str("- none specified");
83        } else {
84            for path in &self.target_files {
85                out.push_str(&format!("- {path}\n"));
86            }
87            if out.ends_with('\n') {
88                out.pop();
89            }
90        }
91        out.push_str("\n\n# Ordered Steps\n");
92        if self.ordered_steps.is_empty() {
93            out.push_str("1. clarify implementation steps");
94        } else {
95            for (idx, step) in self.ordered_steps.iter().enumerate() {
96                out.push_str(&format!("{}. {}\n", idx + 1, step));
97            }
98            if out.ends_with('\n') {
99                out.pop();
100            }
101        }
102        out.push_str("\n\n# Verification\n");
103        out.push_str(if self.verification.trim().is_empty() {
104            "verify_build(action: \"build\")"
105        } else {
106            self.verification.trim()
107        });
108        out.push_str("\n\n# Risks\n");
109        if self.risks.is_empty() {
110            out.push_str("- none noted");
111        } else {
112            for risk in &self.risks {
113                out.push_str(&format!("- {risk}\n"));
114            }
115            if out.ends_with('\n') {
116                out.pop();
117            }
118        }
119        out.push_str("\n\n# Open Questions\n");
120        if self.open_questions.is_empty() {
121            out.push_str("- none");
122        } else {
123            for question in &self.open_questions {
124                out.push_str(&format!("- {question}\n"));
125            }
126            if out.ends_with('\n') {
127                out.pop();
128            }
129        }
130        out.push('\n');
131        out
132    }
133}
134
135fn plan_path() -> std::path::PathBuf {
136    workspace_root().join(".hematite").join("PLAN.md")
137}
138
139pub fn save_plan_handoff(plan: &PlanHandoff) -> Result<(), String> {
140    let path = plan_path();
141    fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
142    fs::write(&path, plan.to_markdown()).map_err(|e| format!("Failed to write plan: {e}"))
143}
144
145pub fn load_plan_handoff() -> Option<PlanHandoff> {
146    let path = plan_path();
147    let content = fs::read_to_string(path).ok()?;
148    parse_plan_handoff(&content)
149}
150
151pub fn parse_plan_handoff(input: &str) -> Option<PlanHandoff> {
152    let sections = collect_sections(input);
153    let goal = sections
154        .get("goal")
155        .map(|s| s.trim().to_string())
156        .unwrap_or_default();
157    let target_files = parse_bullets(
158        sections
159            .get("target files")
160            .map(String::as_str)
161            .unwrap_or(""),
162    );
163    let ordered_steps = parse_ordered(
164        sections
165            .get("ordered steps")
166            .map(String::as_str)
167            .unwrap_or(""),
168    );
169    let verification = sections
170        .get("verification")
171        .map(|s| s.trim().to_string())
172        .unwrap_or_default();
173    let risks = parse_bullets(sections.get("risks").map(String::as_str).unwrap_or(""));
174    let open_questions = parse_bullets(
175        sections
176            .get("open questions")
177            .map(String::as_str)
178            .unwrap_or(""),
179    );
180
181    let plan = PlanHandoff {
182        goal,
183        target_files,
184        ordered_steps,
185        verification,
186        risks,
187        open_questions,
188    };
189    if plan.has_signal() && !plan.goal.trim().is_empty() && !plan.ordered_steps.is_empty() {
190        Some(plan)
191    } else {
192        None
193    }
194}
195
196fn collect_sections(input: &str) -> std::collections::BTreeMap<String, String> {
197    let mut sections = std::collections::BTreeMap::new();
198    let mut current: Option<String> = None;
199    let mut buf = String::new();
200
201    for line in input.lines() {
202        let trimmed = line.trim();
203        if let Some(name) = normalize_heading(trimmed) {
204            if let Some(prev) = current.replace(name) {
205                sections.insert(prev, buf.trim().to_string());
206                buf.clear();
207            }
208            continue;
209        }
210        if current.is_some() {
211            buf.push_str(line);
212            buf.push('\n');
213        }
214    }
215
216    if let Some(prev) = current {
217        sections.insert(prev, buf.trim().to_string());
218    }
219
220    sections
221}
222
223fn normalize_heading(line: &str) -> Option<String> {
224    let heading = line
225        .trim_start_matches('#')
226        .trim()
227        .trim_end_matches(':')
228        .trim();
229    match heading.to_ascii_lowercase().as_str() {
230        "goal" => Some("goal".to_string()),
231        "target files" => Some("target files".to_string()),
232        "ordered steps" => Some("ordered steps".to_string()),
233        "verification" => Some("verification".to_string()),
234        "risks" => Some("risks".to_string()),
235        "open questions" => Some("open questions".to_string()),
236        _ => None,
237    }
238}
239
240fn parse_bullets(section: &str) -> Vec<String> {
241    section
242        .lines()
243        .filter_map(|line| {
244            let trimmed = line.trim();
245            let stripped = trimmed
246                .strip_prefix("- ")
247                .or_else(|| trimmed.strip_prefix("* "))
248                .map(str::trim)?;
249            if stripped.is_empty()
250                || stripped.eq_ignore_ascii_case("none")
251                || stripped.eq_ignore_ascii_case("none specified")
252            {
253                None
254            } else {
255                Some(clean_bullet_path(stripped))
256            }
257        })
258        .filter(|s| !s.is_empty())
259        .collect()
260}
261
262/// Strip markdown formatting and parenthetical annotations from a bullet path.
263/// e.g. "`src/runtime.rs` (startup greeting)" → "src/runtime.rs"
264fn clean_bullet_path(raw: &str) -> String {
265    // Strip all backticks.
266    let no_backticks = raw.replace('`', "");
267    // Truncate at " (" — everything after is a human-readable annotation.
268    let clean = if let Some(idx) = no_backticks.find(" (") {
269        no_backticks[..idx].trim()
270    } else {
271        no_backticks.trim()
272    };
273    clean.to_string()
274}
275
276fn parse_ordered(section: &str) -> Vec<String> {
277    let mut out = Vec::new();
278    for line in section.lines() {
279        let trimmed = line.trim();
280        let Some(dot_idx) = trimmed.find(". ") else {
281            continue;
282        };
283        if trimmed[..dot_idx].chars().all(|c| c.is_ascii_digit()) {
284            let step = trimmed[dot_idx + 2..].trim();
285            if !step.is_empty() {
286                out.push(step.to_string());
287            }
288        }
289    }
290    out
291}
292
293/// Manages a persistent mission plan for the agent in `.hematite/PLAN.md`.
294pub async fn maintain_plan(args: &Value) -> Result<String, String> {
295    let blueprint = args
296        .get("blueprint")
297        .and_then(|v| v.as_str())
298        .ok_or("maintain_plan: 'blueprint' (markdown text) required")?;
299    let plan_path = plan_path();
300
301    fs::create_dir_all(plan_path.parent().unwrap()).map_err(|e| e.to_string())?;
302    fs::write(&plan_path, blueprint).map_err(|e| format!("Failed to write plan: {e}"))?;
303
304    Ok(format!(
305        "Strategic Blueprint updated in .hematite/PLAN.md ({} bytes)",
306        blueprint.len()
307    ))
308}
309
310/// Generates a final walkthrough report for the current session.
311pub async fn generate_walkthrough(args: &Value) -> Result<String, String> {
312    let summary = args
313        .get("summary")
314        .and_then(|v| v.as_str())
315        .ok_or("generate_walkthrough: 'summary' required")?;
316    let path = workspace_root().join(".hematite").join("WALKTHROUGH.md");
317
318    fs::write(&path, summary).map_err(|e| format!("Failed to save walkthrough: {e}"))?;
319
320    Ok(format!(
321        "Walkthrough report saved to .hematite/WALKTHROUGH.md. Session complete!"
322    ))
323}
324
325pub fn get_plan_params() -> Value {
326    json!({
327        "type": "object",
328        "properties": {
329            "blueprint": {
330                "type": "string",
331                "description": "The full markdown content of the strategic blueprint."
332            }
333        },
334        "required": ["blueprint"]
335    })
336}
337
338pub fn get_walkthrough_params() -> Value {
339    json!({
340        "type": "object",
341        "properties": {
342            "summary": {
343                "type": "string",
344                "description": "The full markdown summary of accomplishments."
345            }
346        },
347        "required": ["summary"]
348    })
349}