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}