Skip to main content

sparrow/
plan.rs

1use crate::commands::SlashCommand;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct ReadOnlyPlan {
6    pub task: String,
7    pub summary: String,
8    pub steps: Vec<String>,
9    pub risks: Vec<String>,
10    pub acceptance: Vec<String>,
11    pub estimated_tier: String,
12    pub read_only: bool,
13}
14
15impl ReadOnlyPlan {
16    pub fn render_markdown(&self) -> String {
17        let mut out = String::new();
18        out.push_str("# Sparrow Plan\n\n");
19        out.push_str("Mode: read-only. No tools, edits, exec, or checkpoints were run.\n\n");
20        out.push_str(&format!("Task: {}\n\n", self.task));
21        out.push_str(&format!("Tier: {}\n\n", self.estimated_tier));
22        out.push_str("## Summary\n");
23        out.push_str(&self.summary);
24        out.push_str("\n\n## Steps\n");
25        for (idx, step) in self.steps.iter().enumerate() {
26            out.push_str(&format!("{}. {}\n", idx + 1, step));
27        }
28        out.push_str("\n## Risks\n");
29        for risk in &self.risks {
30            out.push_str(&format!("- {}\n", risk));
31        }
32        out.push_str("\n## Acceptance\n");
33        for item in &self.acceptance {
34            out.push_str(&format!("- {}\n", item));
35        }
36        out
37    }
38}
39
40pub fn build_read_only_plan(task: &str, commands: &[SlashCommand]) -> ReadOnlyPlan {
41    let trimmed = task.trim();
42    let lower = trimmed.to_lowercase();
43    let mut steps = vec![
44        "Inspect the relevant files and current repository state before editing.".into(),
45        "Identify the smallest safe implementation path and any affected tests.".into(),
46        "Apply scoped changes only after the plan is accepted.".into(),
47        "Run targeted checks, then broader build/test gates if code changed.".into(),
48        "Summarize files changed, verification evidence, and remaining risk.".into(),
49    ];
50
51    if lower.contains("github") || lower.contains("ci") || lower.contains("pull request") {
52        steps.insert(
53            1,
54            "Inspect GitHub/CI state and collect failing job logs before changing code.".into(),
55        );
56    }
57    if lower.contains("webview") || lower.contains("ui") || lower.contains("console") {
58        steps
59            .push("Render or screenshot the UI to confirm layout and interaction behavior.".into());
60    }
61    if lower.contains("memory") || lower.contains("session") {
62        steps.push("Verify persistence with a restart-safe session or memory check.".into());
63    }
64
65    let risks = vec![
66        "This plan is read-only; execution still needs approval or a follow-up run.".into(),
67        "Broad requests may need decomposition into smaller phases to keep tests meaningful."
68            .into(),
69        "Existing local unpushed commits must be preserved and not rewritten.".into(),
70    ];
71
72    let mut acceptance = vec![
73        "No filesystem mutation occurs during plan generation.".into(),
74        "The accepted execution path has concrete tests or smoke checks.".into(),
75        "The final answer cites current evidence, not intent.".into(),
76    ];
77    if !commands.is_empty() {
78        acceptance.push(format!(
79            "{} slash command(s) are available for follow-up control.",
80            commands.len()
81        ));
82    }
83
84    ReadOnlyPlan {
85        task: trimmed.into(),
86        summary: summarize_task(trimmed),
87        steps,
88        risks,
89        acceptance,
90        estimated_tier: estimate_tier(trimmed).into(),
91        read_only: true,
92    }
93}
94
95fn summarize_task(task: &str) -> String {
96    if task.is_empty() {
97        return "No task was provided.".into();
98    }
99    format!(
100        "Sparrow should treat this as a controlled execution request: understand the goal, preserve existing work, then act only after the user accepts the plan. The requested task is: {}",
101        task
102    )
103}
104
105fn estimate_tier(task: &str) -> &'static str {
106    let lower = task.to_lowercase();
107    if lower.contains("image") || lower.contains("vision") || lower.contains("screenshot") {
108        "vision"
109    } else if lower.contains("audit")
110        || lower.contains("architecture")
111        || lower.contains("refactor")
112        || lower.contains("autonomie")
113        || lower.contains("complete")
114    {
115        "hard"
116    } else if lower.contains("bug")
117        || lower.contains("fix")
118        || lower.contains("corrige")
119        || lower.contains("test")
120    {
121        "small"
122    } else if task.len() > 260 {
123        "medium"
124    } else {
125        "trivial"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn plan_is_read_only_and_mentions_no_mutation() {
135        let plan = build_read_only_plan("corrige le bug WebView", &[]);
136        assert!(plan.read_only);
137        assert!(plan.render_markdown().contains("No tools, edits, exec"));
138        assert_eq!(plan.estimated_tier, "small");
139    }
140}