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(stripped.to_string())
256            }
257        })
258        .collect()
259}
260
261fn parse_ordered(section: &str) -> Vec<String> {
262    let mut out = Vec::new();
263    for line in section.lines() {
264        let trimmed = line.trim();
265        let Some(dot_idx) = trimmed.find(". ") else {
266            continue;
267        };
268        if trimmed[..dot_idx].chars().all(|c| c.is_ascii_digit()) {
269            let step = trimmed[dot_idx + 2..].trim();
270            if !step.is_empty() {
271                out.push(step.to_string());
272            }
273        }
274    }
275    out
276}
277
278/// Manages a persistent mission plan for the agent in `.hematite/PLAN.md`.
279pub async fn maintain_plan(args: &Value) -> Result<String, String> {
280    let blueprint = args
281        .get("blueprint")
282        .and_then(|v| v.as_str())
283        .ok_or("maintain_plan: 'blueprint' (markdown text) required")?;
284    let plan_path = plan_path();
285
286    fs::create_dir_all(plan_path.parent().unwrap()).map_err(|e| e.to_string())?;
287    fs::write(&plan_path, blueprint).map_err(|e| format!("Failed to write plan: {e}"))?;
288
289    Ok(format!(
290        "Strategic Blueprint updated in .hematite/PLAN.md ({} bytes)",
291        blueprint.len()
292    ))
293}
294
295/// Generates a final walkthrough report for the current session.
296pub async fn generate_walkthrough(args: &Value) -> Result<String, String> {
297    let summary = args
298        .get("summary")
299        .and_then(|v| v.as_str())
300        .ok_or("generate_walkthrough: 'summary' required")?;
301    let path = workspace_root().join(".hematite").join("WALKTHROUGH.md");
302
303    fs::write(&path, summary).map_err(|e| format!("Failed to save walkthrough: {e}"))?;
304
305    Ok(format!(
306        "Walkthrough report saved to .hematite/WALKTHROUGH.md. Session complete!"
307    ))
308}
309
310pub fn get_plan_params() -> Value {
311    json!({
312        "type": "object",
313        "properties": {
314            "blueprint": {
315                "type": "string",
316                "description": "The full markdown content of the strategic blueprint."
317            }
318        },
319        "required": ["blueprint"]
320    })
321}
322
323pub fn get_walkthrough_params() -> Value {
324    json!({
325        "type": "object",
326        "properties": {
327            "summary": {
328                "type": "string",
329                "description": "The full markdown summary of accomplishments."
330            }
331        },
332        "required": ["summary"]
333    })
334}