Skip to main content

roder_roadmap/
control.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    Diagnostic, Document, DocumentSummary, ListOptions, RoadmapStateStore, Task, ThreadAttachment,
9    list_documents, parse_document, validate_document,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RoadmapControlSnapshot {
14    pub documents: Vec<DocumentSummary>,
15    pub selected: Option<RoadmapDocumentControl>,
16    pub total_checked_tasks: usize,
17    pub total_unchecked_tasks: usize,
18    pub next_action: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct RoadmapDocumentControl {
23    pub path: PathBuf,
24    pub title: String,
25    pub goal: String,
26    pub checked_tasks: usize,
27    pub unchecked_tasks: usize,
28    pub focused_task_id: Option<String>,
29    pub selected_thread_id: Option<String>,
30    pub tasks: Vec<RoadmapTaskControl>,
31    pub threads: Vec<ThreadAttachment>,
32    pub diagnostics: Vec<Diagnostic>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct RoadmapTaskControl {
37    pub id: String,
38    pub heading: String,
39    pub checked: bool,
40    pub line: usize,
41    pub level: usize,
42    pub status: RoadmapTaskStatus,
43    pub paths: Vec<String>,
44    pub run_blocks: Vec<String>,
45    pub threads: Vec<ThreadAttachment>,
46    pub recommended_action: String,
47    pub dispatch_prompt: String,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub enum RoadmapTaskStatus {
53    Done,
54    Ready,
55    Assigned,
56    Pending,
57}
58
59pub fn build_control_snapshot(
60    workspace: impl AsRef<Path>,
61    data_dir: impl AsRef<Path>,
62    selected_plan: Option<&str>,
63) -> anyhow::Result<RoadmapControlSnapshot> {
64    let workspace = workspace.as_ref();
65    let documents = list_documents(workspace, ListOptions::default())?;
66    let total_checked_tasks = documents
67        .iter()
68        .map(|document| document.checked_tasks)
69        .sum();
70    let total_unchecked_tasks = documents
71        .iter()
72        .map(|document| document.unchecked_tasks)
73        .sum();
74    let selected_path = selected_plan
75        .map(|plan| resolve_plan_path(workspace, plan))
76        .transpose()?
77        .or_else(|| documents.first().map(|document| document.path.clone()));
78    let selected = selected_path
79        .as_deref()
80        .map(|path| build_document_control(workspace, data_dir.as_ref(), path))
81        .transpose()?;
82    let next_action = next_action(selected.as_ref(), total_unchecked_tasks);
83
84    Ok(RoadmapControlSnapshot {
85        documents,
86        selected,
87        total_checked_tasks,
88        total_unchecked_tasks,
89        next_action,
90    })
91}
92
93pub fn dispatch_prompt(document: &Document, task: &Task) -> String {
94    dispatch_prompt_with_path(document, task, &document.path.display().to_string())
95}
96
97fn dispatch_prompt_with_path(document: &Document, task: &Task, path_label: &str) -> String {
98    let mut prompt = format!(
99        "You are a Roder roadmap worker.\n\
100         Execute the focused roadmap task and keep the roadmap Markdown file as source of truth.\n\n\
101         Roadmap: {}\n\
102         Title: {}\n\
103         Goal: {}\n\n\
104         Task: {}\n\
105         Task ID: {}\n",
106        path_label, document.title, document.goal, task.heading, task.id
107    );
108
109    if !task.paths.is_empty() {
110        prompt.push_str("\nOwned or task-local paths:\n");
111        for path in &task.paths {
112            prompt.push_str(&format!("- {path}\n"));
113        }
114    }
115    if !task.run_blocks.is_empty() {
116        prompt.push_str("\nRun commands:\n");
117        for run in &task.run_blocks {
118            prompt.push_str("```sh\n");
119            prompt.push_str(run);
120            prompt.push_str("\n```\n");
121        }
122    }
123    prompt.push_str(
124        "\nCompletion rule: only mark the task done after the stated acceptance criteria and run commands are satisfied, then record evidence.\n\
125         Report back: end with a concise summary of what changed, run command outcomes, and acceptance evidence so the orchestrator can verify without redoing the work.",
126    );
127    prompt
128}
129
130fn build_document_control(
131    workspace: &Path,
132    data_dir: &Path,
133    path: &Path,
134) -> anyhow::Result<RoadmapDocumentControl> {
135    let content = fs::read_to_string(path)?;
136    let document = parse_document(path, &content);
137    let state = RoadmapStateStore::new(data_dir)
138        .load()?
139        .filter(|state| state.path == path);
140    let threads = state
141        .as_ref()
142        .map(|state| state.threads.clone())
143        .unwrap_or_default();
144    let focused_task_id = state
145        .as_ref()
146        .and_then(|state| state.focused_task_id.clone())
147        .or_else(|| {
148            document
149                .tasks
150                .iter()
151                .find(|task| !task.checked)
152                .or_else(|| document.tasks.first())
153                .map(|task| task.id.clone())
154        });
155    let selected_thread_id = state
156        .as_ref()
157        .and_then(|state| state.attached_thread_id.clone());
158    let checked_tasks = document.tasks.iter().filter(|task| task.checked).count();
159    let unchecked_tasks = document.tasks.len().saturating_sub(checked_tasks);
160    let diagnostics = validate_document(&document).diagnostics;
161    let threads_by_task = threads_by_task(&threads);
162    let tasks = document
163        .tasks
164        .iter()
165        .map(|task| {
166            let task_threads = threads_by_task.get(&task.id).cloned().unwrap_or_default();
167            let status = task_status(task, &task_threads, focused_task_id.as_deref());
168            RoadmapTaskControl {
169                id: task.id.clone(),
170                heading: task.heading.clone(),
171                checked: task.checked,
172                line: task.line,
173                level: task.level,
174                status,
175                paths: task.paths.clone(),
176                run_blocks: task.run_blocks.clone(),
177                threads: task_threads,
178                recommended_action: recommended_task_action(status),
179                dispatch_prompt: dispatch_prompt_with_path(
180                    &document,
181                    task,
182                    &rel(workspace, &document.path),
183                ),
184            }
185        })
186        .collect();
187
188    Ok(RoadmapDocumentControl {
189        path: rel(workspace, &document.path).into(),
190        title: document.title,
191        goal: document.goal,
192        checked_tasks,
193        unchecked_tasks,
194        focused_task_id,
195        selected_thread_id,
196        tasks,
197        threads,
198        diagnostics,
199    })
200}
201
202fn threads_by_task(threads: &[ThreadAttachment]) -> HashMap<String, Vec<ThreadAttachment>> {
203    let mut by_task: HashMap<String, Vec<ThreadAttachment>> = HashMap::new();
204    for thread in threads {
205        if let Some(task_id) = thread.task_id.as_ref() {
206            by_task
207                .entry(task_id.clone())
208                .or_default()
209                .push(thread.clone());
210        }
211    }
212    by_task
213}
214
215fn task_status(
216    task: &Task,
217    threads: &[ThreadAttachment],
218    focused_task_id: Option<&str>,
219) -> RoadmapTaskStatus {
220    if task.checked {
221        RoadmapTaskStatus::Done
222    } else if !threads.is_empty() {
223        RoadmapTaskStatus::Assigned
224    } else if focused_task_id == Some(task.id.as_str()) {
225        RoadmapTaskStatus::Ready
226    } else {
227        RoadmapTaskStatus::Pending
228    }
229}
230
231fn recommended_task_action(status: RoadmapTaskStatus) -> String {
232    match status {
233        RoadmapTaskStatus::Done => "review evidence or reopen if acceptance changed",
234        RoadmapTaskStatus::Ready => "dispatch a worker or continue the focused thread",
235        RoadmapTaskStatus::Assigned => "inspect attached worker progress and steer if blocked",
236        RoadmapTaskStatus::Pending => "wait for earlier tasks or focus this task explicitly",
237    }
238    .to_string()
239}
240
241fn next_action(selected: Option<&RoadmapDocumentControl>, total_unchecked_tasks: usize) -> String {
242    let Some(selected) = selected else {
243        return "create or select a roadmap document".to_string();
244    };
245    if !selected.diagnostics.is_empty() {
246        return "fix roadmap validation diagnostics before dispatching workers".to_string();
247    }
248    if let Some(task) = selected
249        .tasks
250        .iter()
251        .find(|task| task.status == RoadmapTaskStatus::Ready)
252    {
253        return format!("dispatch or continue {}", task.id);
254    }
255    if total_unchecked_tasks == 0 {
256        "all roadmap tasks are complete".to_string()
257    } else {
258        "select the next unchecked roadmap task".to_string()
259    }
260}
261
262fn resolve_plan_path(workspace: &Path, path: &str) -> anyhow::Result<PathBuf> {
263    let path = if path.starts_with("roadmap/") {
264        workspace.join(path)
265    } else if path.ends_with(".md") {
266        workspace.join("roadmap").join(path)
267    } else {
268        workspace.join("roadmap").join(format!("{path}.md"))
269    };
270    if path.parent() == Some(&workspace.join("roadmap"))
271        && path.extension().and_then(|ext| ext.to_str()) == Some("md")
272    {
273        Ok(path)
274    } else {
275        anyhow::bail!("plan must resolve under roadmap/*.md")
276    }
277}
278
279fn rel(workspace: &Path, path: &Path) -> String {
280    path.strip_prefix(workspace)
281        .unwrap_or(path)
282        .display()
283        .to_string()
284        .replace('\\', "/")
285}