1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use roder_api::extension::ToolProviderId;
6use roder_api::tools::{
7 ToolCall, ToolContributor, ToolExecutionContext, ToolExecutor, ToolRegistry, ToolResult,
8 ToolSpec,
9};
10use serde::Deserialize;
11use serde_json::json;
12
13use crate::{
14 Document, ListOptions, RoadmapRuntime, Task, list_documents, parse_document, validate_document,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RoadmapToolActivation {
19 Inactive,
20 RoadmappingMode,
21 ExplicitRequest,
22}
23
24impl RoadmapToolActivation {
25 fn enabled(self) -> bool {
26 matches!(
27 self,
28 RoadmapToolActivation::RoadmappingMode | RoadmapToolActivation::ExplicitRequest
29 )
30 }
31}
32
33#[derive(Debug, Clone)]
34pub struct RoadmapToolContributor {
35 workspace: PathBuf,
36 data_dir: PathBuf,
37 activation: RoadmapToolActivation,
38}
39
40impl RoadmapToolContributor {
41 pub fn new(
42 workspace: impl Into<PathBuf>,
43 data_dir: impl Into<PathBuf>,
44 activation: RoadmapToolActivation,
45 ) -> Self {
46 Self {
47 workspace: workspace.into(),
48 data_dir: data_dir.into(),
49 activation,
50 }
51 }
52}
53
54impl ToolContributor for RoadmapToolContributor {
55 fn id(&self) -> ToolProviderId {
56 "roadmap-tools".to_string()
57 }
58
59 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
60 if !self.activation.enabled() {
61 return Ok(());
62 }
63 let config = RoadmapToolConfig {
64 workspace: self.workspace.clone(),
65 data_dir: self.data_dir.clone(),
66 };
67 registry.register(Arc::new(RoadmapListTool(config.clone())))?;
68 registry.register(Arc::new(RoadmapReadTool(config.clone())))?;
69 registry.register(Arc::new(RoadmapCreateTool(config.clone())))?;
70 registry.register(Arc::new(RoadmapPatchTool(config.clone())))?;
71 registry.register(Arc::new(RoadmapSetTaskStateTool(config.clone())))?;
72 registry.register(Arc::new(RoadmapValidateTool(config.clone())))?;
73 registry.register(Arc::new(RoadmapThreadListTool(config.clone())))?;
74 registry.register(Arc::new(RoadmapThreadSpawnTool(config.clone())))?;
75 registry.register(Arc::new(RoadmapThreadAttachTool(config)))?;
76 Ok(())
77 }
78}
79
80#[derive(Debug, Clone)]
81struct RoadmapToolConfig {
82 workspace: PathBuf,
83 data_dir: PathBuf,
84}
85
86struct RoadmapListTool(RoadmapToolConfig);
87struct RoadmapReadTool(RoadmapToolConfig);
88struct RoadmapCreateTool(RoadmapToolConfig);
89struct RoadmapPatchTool(RoadmapToolConfig);
90struct RoadmapSetTaskStateTool(RoadmapToolConfig);
91struct RoadmapValidateTool(RoadmapToolConfig);
92struct RoadmapThreadListTool(RoadmapToolConfig);
93struct RoadmapThreadSpawnTool(RoadmapToolConfig);
94struct RoadmapThreadAttachTool(RoadmapToolConfig);
95
96#[async_trait::async_trait]
97impl ToolExecutor for RoadmapListTool {
98 fn spec(&self) -> ToolSpec {
99 spec(
100 "roadmap_list",
101 "List roadmap Markdown documents in the workspace roadmap directory.",
102 json!({
103 "type": "object",
104 "properties": {
105 "include_index": { "type": "boolean", "default": false }
106 },
107 "additionalProperties": false
108 }),
109 )
110 }
111
112 async fn execute(
113 &self,
114 ctx: ToolExecutionContext,
115 call: ToolCall,
116 ) -> anyhow::Result<ToolResult> {
117 require_workspace(&ctx)?;
118 let args = parse::<RoadmapListArgs>(&call)?;
119 let documents = list_documents(
120 &self.0.workspace,
121 ListOptions {
122 include_index: args.include_index.unwrap_or(false),
123 },
124 )?;
125 Ok(result(
126 call,
127 format!("found {} roadmap documents", documents.len()),
128 json!({ "documents": documents }),
129 false,
130 ))
131 }
132}
133
134#[async_trait::async_trait]
135impl ToolExecutor for RoadmapReadTool {
136 fn spec(&self) -> ToolSpec {
137 spec(
138 "roadmap_read",
139 "Read and parse one roadmap Markdown document.",
140 json!({
141 "type": "object",
142 "properties": { "path": { "type": "string" } },
143 "required": ["path"],
144 "additionalProperties": false
145 }),
146 )
147 }
148
149 async fn execute(
150 &self,
151 ctx: ToolExecutionContext,
152 call: ToolCall,
153 ) -> anyhow::Result<ToolResult> {
154 require_workspace(&ctx)?;
155 let args = parse::<RoadmapPathArgs>(&call)?;
156 let path = resolve_roadmap_path(&self.0.workspace, &args.path)?;
157 let document = read_document(&path)?;
158 Ok(document_result(call, "read roadmap", &document, json!({})))
159 }
160}
161
162#[async_trait::async_trait]
163impl ToolExecutor for RoadmapCreateTool {
164 fn spec(&self) -> ToolSpec {
165 spec(
166 "roadmap_create",
167 "Create a new roadmap Markdown document using the next phase number.",
168 json!({
169 "type": "object",
170 "properties": {
171 "slug": { "type": "string" },
172 "title": { "type": "string" },
173 "goal": { "type": "string" }
174 },
175 "required": ["slug", "title"],
176 "additionalProperties": false
177 }),
178 )
179 }
180
181 async fn execute(
182 &self,
183 ctx: ToolExecutionContext,
184 call: ToolCall,
185 ) -> anyhow::Result<ToolResult> {
186 require_workspace(&ctx)?;
187 let args = parse::<RoadmapCreateArgs>(&call)?;
188 let slug = sanitize_slug(&args.slug)?;
189 let roadmap_dir = self.0.workspace.join("roadmap");
190 fs::create_dir_all(&roadmap_dir)?;
191 let phase = next_phase_number(&roadmap_dir)?;
192 let path = roadmap_dir.join(format!("{phase:02}-{slug}.md"));
193 if path.exists() {
194 anyhow::bail!("roadmap already exists: {}", path.display());
195 }
196 let title = args.title.trim();
197 if title.is_empty() {
198 anyhow::bail!("title must not be empty");
199 }
200 let goal = args
201 .goal
202 .as_deref()
203 .filter(|goal| !goal.trim().is_empty())
204 .unwrap_or("Describe the intended outcome.");
205 let content = format!(
206 "# {title} Implementation Plan\n\n**Goal:** {goal}\n**Architecture:** Document the architecture before implementation.\n**Tech Stack:** Rust.\n\n## Owned Paths\n\n- Create: `roadmap/{phase:02}-{slug}.md`\n\n## Tasks\n\n- [ ] Draft the implementation plan\n\nRun:\n\n```sh\ncargo test -p roder-roadmap\n```\n\nAcceptance:\n- The roadmap is actionable and validated.\n\n## Phase Acceptance\n\n- [ ] Plan is complete.\n"
207 );
208 fs::write(&path, content)?;
209 let document = read_document(&path)?;
210 Ok(document_result(
211 call,
212 "created roadmap",
213 &document,
214 json!({ "changed_path": document.path }),
215 ))
216 }
217}
218
219#[async_trait::async_trait]
220impl ToolExecutor for RoadmapPatchTool {
221 fn spec(&self) -> ToolSpec {
222 spec(
223 "roadmap_patch",
224 "Replace exact text in a roadmap document or the repo-local roadmap-planning skill.",
225 json!({
226 "type": "object",
227 "properties": {
228 "path": { "type": "string" },
229 "old_string": { "type": "string" },
230 "new_string": { "type": "string" }
231 },
232 "required": ["path", "old_string", "new_string"],
233 "additionalProperties": false
234 }),
235 )
236 }
237
238 async fn execute(
239 &self,
240 ctx: ToolExecutionContext,
241 call: ToolCall,
242 ) -> anyhow::Result<ToolResult> {
243 require_workspace(&ctx)?;
244 let args = parse::<RoadmapPatchArgs>(&call)?;
245 if args.old_string.is_empty() {
246 anyhow::bail!("old_string must not be empty");
247 }
248 let path = resolve_allowed_write_path(&self.0.workspace, &args.path)?;
249 let content = fs::read_to_string(&path)?;
250 let replacements = content.matches(&args.old_string).count();
251 if replacements == 0 {
252 return Ok(result(
253 call,
254 "old_string does not match file".to_string(),
255 json!({ "changed_path": path, "replacements": 0 }),
256 true,
257 ));
258 }
259 fs::write(&path, content.replace(&args.old_string, &args.new_string))?;
260 let document = if is_roadmap_file(&self.0.workspace, &path) {
261 Some(read_document(&path)?)
262 } else {
263 None
264 };
265 Ok(tool_result_for_optional_document(
266 call,
267 "patched roadmap text",
268 document.as_ref(),
269 json!({ "changed_path": path, "replacements": replacements }),
270 false,
271 ))
272 }
273}
274
275#[async_trait::async_trait]
276impl ToolExecutor for RoadmapSetTaskStateTool {
277 fn spec(&self) -> ToolSpec {
278 spec(
279 "roadmap_set_task_state",
280 "Mark a roadmap task done or open. Marking done requires non-empty evidence.",
281 json!({
282 "type": "object",
283 "properties": {
284 "path": { "type": "string" },
285 "task_id": { "type": "string" },
286 "checked": { "type": "boolean" },
287 "evidence": { "type": "string" }
288 },
289 "required": ["path", "task_id", "checked"],
290 "additionalProperties": false
291 }),
292 )
293 }
294
295 async fn execute(
296 &self,
297 ctx: ToolExecutionContext,
298 call: ToolCall,
299 ) -> anyhow::Result<ToolResult> {
300 require_workspace(&ctx)?;
301 let args = parse::<RoadmapSetTaskStateArgs>(&call)?;
302 let evidence = args.evidence.unwrap_or_default();
303 if args.checked && evidence.trim().is_empty() {
304 anyhow::bail!("evidence is required when marking a roadmap task done");
305 }
306 let mut runtime = runtime(&self.0);
307 runtime.set_roadmap_task(&args.path, &args.task_id, args.checked, &evidence)?;
308 let path = resolve_roadmap_path(&self.0.workspace, &args.path)?;
309 let document = read_document(&path)?;
310 Ok(document_result(
311 call,
312 "updated roadmap task state",
313 &document,
314 json!({ "changed_path": document.path, "task_id": args.task_id }),
315 ))
316 }
317}
318
319#[async_trait::async_trait]
320impl ToolExecutor for RoadmapValidateTool {
321 fn spec(&self) -> ToolSpec {
322 spec(
323 "roadmap_validate",
324 "Validate one roadmap document and return diagnostics.",
325 json!({
326 "type": "object",
327 "properties": { "path": { "type": "string" } },
328 "required": ["path"],
329 "additionalProperties": false
330 }),
331 )
332 }
333
334 async fn execute(
335 &self,
336 ctx: ToolExecutionContext,
337 call: ToolCall,
338 ) -> anyhow::Result<ToolResult> {
339 require_workspace(&ctx)?;
340 let args = parse::<RoadmapPathArgs>(&call)?;
341 let path = resolve_roadmap_path(&self.0.workspace, &args.path)?;
342 let document = read_document(&path)?;
343 let validation = validate_document(&document);
344 Ok(result(
345 call,
346 format!("validation diagnostics: {}", validation.diagnostics.len()),
347 json!({
348 "path": document.path,
349 "document_id": document.id,
350 "diagnostics": validation.diagnostics,
351 "next_unchecked_task": next_unchecked_task(&document),
352 }),
353 false,
354 ))
355 }
356}
357
358#[async_trait::async_trait]
359impl ToolExecutor for RoadmapThreadListTool {
360 fn spec(&self) -> ToolSpec {
361 spec(
362 "roadmap_thread_list",
363 "List thread attachments for a roadmap document.",
364 json!({
365 "type": "object",
366 "properties": { "path": { "type": "string" } },
367 "required": ["path"],
368 "additionalProperties": false
369 }),
370 )
371 }
372
373 async fn execute(
374 &self,
375 ctx: ToolExecutionContext,
376 call: ToolCall,
377 ) -> anyhow::Result<ToolResult> {
378 require_workspace(&ctx)?;
379 let args = parse::<RoadmapPathArgs>(&call)?;
380 let runtime = runtime(&self.0);
381 let threads = runtime.list_roadmap_threads(&args.path)?;
382 Ok(result(
383 call,
384 format!("found {} roadmap thread attachments", threads.len()),
385 json!({ "threads": threads }),
386 false,
387 ))
388 }
389}
390
391#[async_trait::async_trait]
392impl ToolExecutor for RoadmapThreadSpawnTool {
393 fn spec(&self) -> ToolSpec {
394 spec(
395 "roadmap_thread_spawn",
396 "Create a new thread attachment for a roadmap task without mutating transcript history.",
397 json!({
398 "type": "object",
399 "properties": {
400 "path": { "type": "string" },
401 "task_id": { "type": "string" }
402 },
403 "required": ["path", "task_id"],
404 "additionalProperties": false
405 }),
406 )
407 }
408
409 async fn execute(
410 &self,
411 ctx: ToolExecutionContext,
412 call: ToolCall,
413 ) -> anyhow::Result<ToolResult> {
414 require_workspace(&ctx)?;
415 let args = parse::<RoadmapThreadTaskArgs>(&call)?;
416 let mut runtime = runtime(&self.0);
417 let attachment = runtime.spawn_roadmap_thread(&args.path, &args.task_id)?;
418 Ok(result(
419 call,
420 "spawned roadmap thread attachment".to_string(),
421 json!({ "thread": attachment, "task_id": args.task_id }),
422 false,
423 ))
424 }
425}
426
427#[async_trait::async_trait]
428impl ToolExecutor for RoadmapThreadAttachTool {
429 fn spec(&self) -> ToolSpec {
430 spec(
431 "roadmap_thread_attach",
432 "Attach an existing thread id to a roadmap task without mutating transcript history.",
433 json!({
434 "type": "object",
435 "properties": {
436 "path": { "type": "string" },
437 "task_id": { "type": "string" },
438 "thread_id": { "type": "string" },
439 "title": { "type": "string" }
440 },
441 "required": ["path", "task_id", "thread_id"],
442 "additionalProperties": false
443 }),
444 )
445 }
446
447 async fn execute(
448 &self,
449 ctx: ToolExecutionContext,
450 call: ToolCall,
451 ) -> anyhow::Result<ToolResult> {
452 require_workspace(&ctx)?;
453 let args = parse::<RoadmapThreadAttachArgs>(&call)?;
454 let mut runtime = runtime(&self.0);
455 let attachment = runtime.attach_roadmap_thread(
456 &args.path,
457 &args.task_id,
458 &args.thread_id,
459 args.title,
460 )?;
461 Ok(result(
462 call,
463 "attached roadmap thread".to_string(),
464 json!({ "thread": attachment, "task_id": args.task_id }),
465 false,
466 ))
467 }
468}
469
470#[derive(Debug, Deserialize)]
471struct RoadmapListArgs {
472 include_index: Option<bool>,
473}
474
475#[derive(Debug, Deserialize)]
476struct RoadmapPathArgs {
477 path: String,
478}
479
480#[derive(Debug, Deserialize)]
481struct RoadmapCreateArgs {
482 slug: String,
483 title: String,
484 goal: Option<String>,
485}
486
487#[derive(Debug, Deserialize)]
488struct RoadmapPatchArgs {
489 path: String,
490 old_string: String,
491 new_string: String,
492}
493
494#[derive(Debug, Deserialize)]
495struct RoadmapSetTaskStateArgs {
496 path: String,
497 task_id: String,
498 checked: bool,
499 evidence: Option<String>,
500}
501
502#[derive(Debug, Deserialize)]
503struct RoadmapThreadTaskArgs {
504 path: String,
505 task_id: String,
506}
507
508#[derive(Debug, Deserialize)]
509struct RoadmapThreadAttachArgs {
510 path: String,
511 task_id: String,
512 thread_id: String,
513 title: Option<String>,
514}
515
516fn runtime(config: &RoadmapToolConfig) -> RoadmapRuntime {
517 RoadmapRuntime::new(&config.workspace, &config.data_dir)
518}
519
520fn parse<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> anyhow::Result<T> {
521 serde_json::from_value(call.arguments.clone())
522 .map_err(|err| anyhow::anyhow!("invalid {} arguments: {err}", call.name))
523}
524
525fn spec(name: &str, description: &str, parameters: serde_json::Value) -> ToolSpec {
526 ToolSpec {
527 name: name.to_string(),
528 description: description.to_string(),
529 parameters,
530 }
531}
532
533fn result(call: ToolCall, text: String, data: serde_json::Value, is_error: bool) -> ToolResult {
534 ToolResult {
535 id: call.id,
536 name: call.name,
537 text,
538 data,
539 is_error,
540 }
541}
542
543fn document_result(
544 call: ToolCall,
545 text: &str,
546 document: &Document,
547 extra: serde_json::Value,
548) -> ToolResult {
549 tool_result_for_optional_document(call, text, Some(document), extra, false)
550}
551
552fn tool_result_for_optional_document(
553 call: ToolCall,
554 text: &str,
555 document: Option<&Document>,
556 extra: serde_json::Value,
557 is_error: bool,
558) -> ToolResult {
559 let mut data = match extra {
560 serde_json::Value::Object(map) => map,
561 _ => serde_json::Map::new(),
562 };
563 if let Some(document) = document {
564 data.insert("document".to_string(), json!(document));
565 data.insert("path".to_string(), json!(document.path));
566 data.insert("document_id".to_string(), json!(document.id));
567 data.insert(
568 "diagnostics".to_string(),
569 json!(validate_document(document).diagnostics),
570 );
571 data.insert(
572 "next_unchecked_task".to_string(),
573 json!(next_unchecked_task(document)),
574 );
575 }
576 result(
577 call,
578 text.to_string(),
579 serde_json::Value::Object(data),
580 is_error,
581 )
582}
583
584fn read_document(path: &Path) -> anyhow::Result<Document> {
585 let content = fs::read_to_string(path)?;
586 Ok(parse_document(path, &content))
587}
588
589fn resolve_roadmap_path(workspace: &Path, path: &str) -> anyhow::Result<PathBuf> {
590 let workspace = workspace.canonicalize()?;
591 let candidate = if Path::new(path).is_absolute() {
592 PathBuf::from(path)
593 } else {
594 workspace.join(path)
595 };
596 let candidate = candidate.canonicalize()?;
597 if is_roadmap_file(&workspace, &candidate) {
598 Ok(candidate)
599 } else {
600 anyhow::bail!(
601 "roadmap path must be under {}",
602 workspace.join("roadmap").display()
603 )
604 }
605}
606
607fn resolve_allowed_write_path(workspace: &Path, path: &str) -> anyhow::Result<PathBuf> {
608 let workspace = workspace.canonicalize()?;
609 let candidate = if Path::new(path).is_absolute() {
610 PathBuf::from(path)
611 } else {
612 workspace.join(path)
613 };
614 let candidate = candidate.canonicalize()?;
615 if is_roadmap_file(&workspace, &candidate)
616 || candidate.starts_with(workspace.join(".agents/skills/roadmap-planning"))
617 {
618 Ok(candidate)
619 } else {
620 anyhow::bail!(
621 "roadmap write tools are limited to roadmap/*.md and .agents/skills/roadmap-planning"
622 )
623 }
624}
625
626fn is_roadmap_file(workspace: &Path, path: &Path) -> bool {
627 path.parent() == Some(&workspace.join("roadmap"))
628 && path.extension().and_then(|ext| ext.to_str()) == Some("md")
629}
630
631fn require_workspace(ctx: &ToolExecutionContext) -> anyhow::Result<()> {
632 ctx.require_workspace().map(|_| ())
633}
634
635fn sanitize_slug(slug: &str) -> anyhow::Result<String> {
636 let slug = slug.trim();
637 if slug.is_empty() {
638 anyhow::bail!("slug must not be empty");
639 }
640 if !slug
641 .chars()
642 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
643 {
644 anyhow::bail!("slug must contain only lowercase letters, digits, and hyphens");
645 }
646 Ok(slug.to_string())
647}
648
649fn next_phase_number(roadmap_dir: &Path) -> anyhow::Result<u32> {
650 let mut max_phase = 0;
651 for entry in fs::read_dir(roadmap_dir)? {
652 let entry = entry?;
653 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
654 continue;
655 };
656 let Some(prefix) = name.split_once('-').map(|(prefix, _)| prefix) else {
657 continue;
658 };
659 if let Ok(phase) = prefix.parse::<u32>() {
660 max_phase = max_phase.max(phase);
661 }
662 }
663 Ok(max_phase + 1)
664}
665
666fn next_unchecked_task(document: &Document) -> Option<&Task> {
667 document.tasks.iter().find(|task| !task.checked)
668}