Skip to main content

ralph/commands/
prompt.rs

1//! Prompt inspection/preview commands.
2//!
3//! Responsibilities:
4//! - Render preview of final prompts that would be sent to runners
5//! - Provide prompt management commands (list, show, export, sync, diff)
6//! - Make prompt compilation observable and auditable
7//!
8//! Not handled here:
9//! - Actual prompt execution or runner invocation
10//! - Prompt template editing (use filesystem or editor)
11//! - Prompt versioning or rollback
12//!
13//! Invariants/assumptions:
14//! - Prompt templates exist in `crates/ralph/assets/prompts/` or `.ralph/prompts/`
15//! - Override paths follow the pattern `.ralph/prompts/<name>.md`
16//! - Preview rendering re-uses production prompt rendering for accuracy
17//!
18//! Renders the exact final prompt that would be sent to a runner for:
19//! - worker (single-phase / phase1 / phase2)
20//! - scan
21//! - task builder
22//!
23//! The logic intentionally re-uses existing prompt rendering + wrappers so that
24//! previews stay accurate as runtime behavior evolves.
25
26use crate::config;
27use crate::constants::paths::{
28    SCAN_OVERRIDE_PATH, TASK_BUILDER_OVERRIDE_PATH, WORKER_OVERRIDE_PATH,
29};
30use crate::contracts::ProjectType;
31use crate::promptflow::{self, PromptPolicy};
32use crate::prompts_internal::management as prompt_mgmt;
33use crate::{prompts, queue};
34use anyhow::{Context, Result, bail};
35use std::fs;
36use std::path::{Path, PathBuf};
37
38/// List all available prompt templates.
39pub fn list_prompts(repo_root: &Path) -> Result<()> {
40    let templates = prompt_mgmt::list_templates(repo_root);
41
42    println!("Available prompt templates ({} total):\n", templates.len());
43
44    // Find max name length for alignment
45    let max_name_len = templates.iter().map(|t| t.name.len()).max().unwrap_or(0);
46
47    for t in templates {
48        let status = if t.has_override { " [override]" } else { "" };
49        println!(
50            "  {:width$}  {}{}",
51            t.name,
52            t.description,
53            status,
54            width = max_name_len
55        );
56    }
57
58    println!("\nOverride paths: .ralph/prompts/<name>.md");
59    println!("Use 'ralph prompt show <name> --raw' to view raw embedded content");
60
61    Ok(())
62}
63
64/// Show a specific prompt template.
65pub fn show_prompt(repo_root: &Path, name: &str, raw: bool) -> Result<()> {
66    let id = prompt_mgmt::parse_template_name(name)
67        .ok_or_else(|| anyhow::anyhow!("Unknown template name: '{}'", name))?;
68
69    let content = if raw {
70        prompt_mgmt::get_embedded_content(id).to_string()
71    } else {
72        prompt_mgmt::get_effective_content(repo_root, id)?
73    };
74
75    print!("{}", content);
76    Ok(())
77}
78
79/// Export prompt(s) to .ralph/prompts/.
80pub fn export_prompts(repo_root: &Path, name: Option<&str>, force: bool) -> Result<()> {
81    let ralph_version = env!("CARGO_PKG_VERSION");
82
83    if let Some(n) = name {
84        // Export single template
85        let id = prompt_mgmt::parse_template_name(n)
86            .ok_or_else(|| anyhow::anyhow!("Unknown template name: '{}'", n))?;
87
88        let file_name = prompt_mgmt::template_file_name(id);
89        let written = prompt_mgmt::export_template(repo_root, id, force, ralph_version)?;
90
91        if written {
92            println!("Exported {} to .ralph/prompts/{}.md", file_name, file_name);
93        } else {
94            println!(
95                "Skipped {}: file already exists (use --force to overwrite)",
96                file_name
97            );
98        }
99    } else {
100        // Export all templates
101        let templates = prompt_mgmt::all_template_ids();
102        let mut exported = 0;
103        let mut skipped = 0;
104
105        for id in templates {
106            let file_name = prompt_mgmt::template_file_name(id);
107            match prompt_mgmt::export_template(repo_root, id, force, ralph_version) {
108                Ok(written) => {
109                    if written {
110                        exported += 1;
111                        println!("Exported {}", file_name);
112                    } else {
113                        skipped += 1;
114                        println!("Skipped {}: already exists", file_name);
115                    }
116                }
117                Err(e) => {
118                    eprintln!("Error exporting {}: {}", file_name, e);
119                }
120            }
121        }
122
123        println!("\nExported {} templates, skipped {}", exported, skipped);
124        if skipped > 0 && !force {
125            println!("Use --force to overwrite existing files");
126        }
127    }
128
129    Ok(())
130}
131
132/// Sync prompts with embedded defaults.
133pub fn sync_prompts(repo_root: &Path, dry_run: bool, force: bool) -> Result<()> {
134    let ralph_version = env!("CARGO_PKG_VERSION");
135    let templates = prompt_mgmt::all_template_ids();
136
137    let mut up_to_date = Vec::new();
138    let mut outdated = Vec::new();
139    let mut user_modified = Vec::new();
140    let mut missing = Vec::new();
141
142    // Categorize all templates
143    for id in &templates {
144        let file_name = prompt_mgmt::template_file_name(*id);
145        let status = prompt_mgmt::check_sync_status(repo_root, *id)?;
146
147        match status {
148            prompt_mgmt::SyncStatus::UpToDate => up_to_date.push(file_name),
149            prompt_mgmt::SyncStatus::Outdated => outdated.push((file_name, *id)),
150            prompt_mgmt::SyncStatus::UserModified => user_modified.push((file_name, *id)),
151            prompt_mgmt::SyncStatus::Unknown => user_modified.push((file_name, *id)),
152            prompt_mgmt::SyncStatus::Missing => missing.push((file_name, *id)),
153        }
154    }
155
156    if dry_run {
157        println!("Dry run - no changes will be made:\n");
158
159        if !outdated.is_empty() {
160            println!("Would update ({}):", outdated.len());
161            for (name, _) in &outdated {
162                println!("  {}", name);
163            }
164        }
165
166        if !missing.is_empty() {
167            println!("Would create ({}):", missing.len());
168            for (name, _) in &missing {
169                println!("  {}", name);
170            }
171        }
172
173        if !user_modified.is_empty() {
174            println!("Would skip (user modified) ({}):", user_modified.len());
175            for (name, _) in &user_modified {
176                println!("  {}", name);
177            }
178        }
179
180        if !up_to_date.is_empty() {
181            println!("Up to date ({}):", up_to_date.len());
182            for name in &up_to_date {
183                println!("  {}", name);
184            }
185        }
186
187        return Ok(());
188    }
189
190    // Perform sync
191    let mut updated = 0;
192    let mut skipped = 0;
193    let mut created = 0;
194
195    // Update outdated
196    for (name, id) in outdated {
197        match prompt_mgmt::export_template(repo_root, id, true, ralph_version) {
198            Ok(_) => {
199                println!("Updated {} (outdated)", name);
200                updated += 1;
201            }
202            Err(e) => {
203                eprintln!("Error updating {}: {}", name, e);
204                skipped += 1;
205            }
206        }
207    }
208
209    // Create missing
210    for (name, id) in missing {
211        match prompt_mgmt::export_template(repo_root, id, false, ralph_version) {
212            Ok(_) => {
213                println!("Created {}", name);
214                created += 1;
215            }
216            Err(e) => {
217                eprintln!("Error creating {}: {}", name, e);
218                skipped += 1;
219            }
220        }
221    }
222
223    // Handle user modified
224    for (name, id) in user_modified {
225        if force {
226            match prompt_mgmt::export_template(repo_root, id, true, ralph_version) {
227                Ok(_) => {
228                    println!("Overwrote {} (user modified, --force)", name);
229                    updated += 1;
230                }
231                Err(e) => {
232                    eprintln!("Error overwriting {}: {}", name, e);
233                    skipped += 1;
234                }
235            }
236        } else {
237            println!("Skipped {} (user modified, use --force to overwrite)", name);
238            skipped += 1;
239        }
240    }
241
242    println!(
243        "\nSync complete: {} updated, {} created, {} skipped",
244        updated, created, skipped
245    );
246
247    Ok(())
248}
249
250/// Show diff between user override and embedded default.
251pub fn diff_prompt(repo_root: &Path, name: &str) -> Result<()> {
252    let id = prompt_mgmt::parse_template_name(name)
253        .ok_or_else(|| anyhow::anyhow!("Unknown template name: '{}'", name))?;
254
255    match prompt_mgmt::generate_diff(repo_root, id)? {
256        Some(diff) => {
257            print!("{}", diff);
258        }
259        None => {
260            println!("No local override for '{}' - using embedded default", name);
261        }
262    }
263
264    Ok(())
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum WorkerMode {
269    /// Show the prompt for phase 1 (planning).
270    Phase1,
271    /// Show the prompt for phase 2 (implementation). Requires plan text.
272    Phase2,
273    /// Show the prompt for phase 3 (code review).
274    Phase3,
275    /// Show the combined single-phase prompt (plan+implement).
276    Single,
277}
278
279#[derive(Debug, Clone)]
280pub struct WorkerPromptOptions {
281    /// If None, we will attempt to pick the first todo task from the queue.
282    pub task_id: Option<String>,
283    pub mode: WorkerMode,
284    /// RepoPrompt planning requirement already resolved (flags + config).
285    pub repoprompt_plan_required: bool,
286    /// RepoPrompt tooling reminder injection already resolved (flags + config).
287    pub repoprompt_tool_injection: bool,
288    /// Total iteration count to simulate when rendering prompts.
289    pub iterations: u8,
290    /// 1-based iteration index to simulate when rendering prompts.
291    pub iteration_index: u8,
292
293    /// Optional explicit plan file for Phase 2.
294    /// If omitted in Phase 2, we try the cached plan at `.ralph/cache/plans/{{TASK_ID}}.md`.
295    pub plan_file: Option<PathBuf>,
296    /// Optional inline plan override (takes precedence over plan_file/cache).
297    pub plan_text: Option<String>,
298
299    /// Print a small header explaining what was selected.
300    pub explain: bool,
301}
302
303#[derive(Debug, Clone)]
304pub struct ScanPromptOptions {
305    pub focus: String,
306    pub mode: crate::cli::scan::ScanMode,
307    pub repoprompt_tool_injection: bool,
308    pub explain: bool,
309}
310
311#[derive(Debug, Clone)]
312pub struct TaskBuilderPromptOptions {
313    pub request: String,
314    pub hint_tags: String,
315    pub hint_scope: String,
316    pub repoprompt_tool_injection: bool,
317    pub explain: bool,
318}
319
320fn worker_template_source(repo_root: &Path) -> &'static str {
321    if repo_root.join(WORKER_OVERRIDE_PATH).exists() {
322        WORKER_OVERRIDE_PATH
323    } else {
324        "(embedded default)"
325    }
326}
327
328fn scan_template_source(repo_root: &Path) -> &'static str {
329    if repo_root.join(SCAN_OVERRIDE_PATH).exists() {
330        SCAN_OVERRIDE_PATH
331    } else {
332        "(embedded default)"
333    }
334}
335
336fn task_builder_template_source(repo_root: &Path) -> &'static str {
337    if repo_root.join(TASK_BUILDER_OVERRIDE_PATH).exists() {
338        TASK_BUILDER_OVERRIDE_PATH
339    } else {
340        "(embedded default)"
341    }
342}
343
344/// Resolve a task id for worker prompt preview:
345/// - If provided explicitly, use it.
346/// - Else load queue and pick first todo.
347/// - Else error with a clear message.
348fn resolve_worker_task_id(resolved: &config::Resolved, task_id: Option<String>) -> Result<String> {
349    if let Some(id) = task_id {
350        let trimmed = id.trim();
351        if trimmed.is_empty() {
352            bail!("--task-id was provided but is empty");
353        }
354        return Ok(trimmed.to_string());
355    }
356
357    // Best-effort: mirror runtime selection.
358    // Runtime prefers resuming a `doing` task, otherwise the first runnable `todo`.
359    if resolved.queue_path.exists() {
360        let queue_file = queue::load_queue(&resolved.queue_path)
361            .with_context(|| format!("read {}", resolved.queue_path.display()))?;
362
363        let done_file = if resolved.done_path.exists() {
364            Some(
365                queue::load_queue(&resolved.done_path)
366                    .with_context(|| format!("read {}", resolved.done_path.display()))?,
367            )
368        } else {
369            None
370        };
371
372        let options = queue::operations::RunnableSelectionOptions::new(false, true);
373        if let Some(idx) =
374            queue::operations::select_runnable_task_index(&queue_file, done_file.as_ref(), options)
375            && let Some(task) = queue_file.tasks.get(idx)
376        {
377            return Ok(task.id.trim().to_string());
378        }
379    }
380
381    bail!(
382        "No doing/todo tasks found to infer a worker task id. Provide --task-id (e.g., RQ-0001) to preview the worker prompt."
383    );
384}
385
386/// Load plan text for Phase 2 prompt preview.
387///
388/// NOTE: This function is ONLY used by the `ralph prompt` command for preview/inspection.
389/// Actual runtime execution (`ralph run`) extracts the plan directly from Phase 1 output
390/// and will error if no plan exists. This function uses a placeholder when missing
391/// to allow previewing Phase 2 prompts even when no cached plan exists.
392fn load_plan_text_for_phase2(
393    repo_root: &Path,
394    task_id: &str,
395    plan_text: Option<String>,
396    plan_file: Option<PathBuf>,
397) -> Result<String> {
398    if let Some(text) = plan_text {
399        let trimmed = text.trim();
400        if trimmed.is_empty() {
401            bail!("--plan-text was provided but is empty");
402        }
403        return Ok(trimmed.to_string());
404    }
405
406    if let Some(path) = plan_file {
407        let raw = fs::read_to_string(&path)
408            .with_context(|| format!("read plan file {}", path.display()))?;
409        let trimmed = raw.trim();
410        if trimmed.is_empty() {
411            bail!("Plan file is empty: {}", path.display());
412        }
413        return Ok(trimmed.to_string());
414    }
415
416    // For preview command only: if cache is missing, use placeholder instead of erroring.
417    // Runtime execution will still error appropriately since it extracts plan from Phase 1 output.
418    match promptflow::read_plan_cache(repo_root, task_id) {
419        Ok(plan) => Ok(plan),
420        Err(_) => {
421            let cache_path = promptflow::plan_cache_path(repo_root, task_id);
422            Ok(format!(
423                "*No plan file found*\n\nNo plan file was found at {}. Please proceed with implementation based on the task requirements.",
424                cache_path.display()
425            ))
426        }
427    }
428}
429
430fn load_phase2_final_response_for_phase3(repo_root: &Path, task_id: &str) -> String {
431    match promptflow::read_phase2_final_response_cache(repo_root, task_id) {
432        Ok(text) => text,
433        Err(err) => {
434            log::warn!(
435                "Phase 2 final response cache unavailable for {}: {}",
436                task_id,
437                err
438            );
439            "(Phase 2 final response unavailable; cache missing.)".to_string()
440        }
441    }
442}
443
444pub fn build_worker_prompt(
445    resolved: &config::Resolved,
446    opts: WorkerPromptOptions,
447) -> Result<String> {
448    let task_id = resolve_worker_task_id(resolved, opts.task_id)?;
449    if opts.iterations == 0 {
450        bail!("--iterations must be >= 1");
451    }
452    if opts.iteration_index == 0 {
453        bail!("--iteration-index must be >= 1");
454    }
455    if opts.iteration_index > opts.iterations {
456        bail!(
457            "--iteration-index ({}) cannot exceed --iterations ({})",
458            opts.iteration_index,
459            opts.iterations
460        );
461    }
462
463    let template = prompts::load_worker_prompt(&resolved.repo_root)?;
464    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
465    let base_prompt =
466        prompts::render_worker_prompt(&template, &task_id, project_type, &resolved.config)?;
467    let base_prompt =
468        prompts::wrap_with_instruction_files(&resolved.repo_root, &base_prompt, &resolved.config)?;
469
470    let policy = PromptPolicy {
471        repoprompt_plan_required: opts.repoprompt_plan_required,
472        repoprompt_tool_injection: opts.repoprompt_tool_injection,
473    };
474    let is_followup = opts.iteration_index > 1;
475    let is_final_iteration = opts.iteration_index == opts.iterations;
476    let iteration_context = if is_followup {
477        prompts::ITERATION_CONTEXT_REFINEMENT
478    } else {
479        ""
480    };
481    let iteration_completion_block = if is_final_iteration {
482        ""
483    } else {
484        prompts::ITERATION_COMPLETION_BLOCK
485    };
486    let phase3_completion_guidance = if is_final_iteration {
487        prompts::PHASE3_COMPLETION_GUIDANCE_FINAL
488    } else {
489        prompts::PHASE3_COMPLETION_GUIDANCE_NONFINAL
490    };
491
492    let configured_phases = resolved.config.agent.phases.unwrap_or(2);
493    let total_phases = match opts.mode {
494        WorkerMode::Phase3 => 3,
495        WorkerMode::Single => 1,
496        _ => configured_phases.clamp(2, 3),
497    };
498
499    let load_completion_checklist = || -> Result<String> {
500        let template = prompts::load_completion_checklist(&resolved.repo_root)?;
501        prompts::render_completion_checklist(&template, &task_id, &resolved.config, false)
502    };
503
504    let prompt = match opts.mode {
505        WorkerMode::Phase1 => {
506            let phase1_template = prompts::load_worker_phase1_prompt(&resolved.repo_root)?;
507            promptflow::build_phase1_prompt(
508                &phase1_template,
509                &base_prompt,
510                iteration_context,
511                promptflow::PHASE1_TASK_REFRESH_REQUIRED_INSTRUCTION,
512                &task_id,
513                total_phases,
514                &policy,
515                &resolved.config,
516            )?
517        }
518        WorkerMode::Phase2 => {
519            let plan_text = load_plan_text_for_phase2(
520                &resolved.repo_root,
521                &task_id,
522                opts.plan_text,
523                opts.plan_file,
524            )?;
525            if total_phases == 3 {
526                let handoff_template = prompts::load_phase2_handoff_checklist(&resolved.repo_root)?;
527                let handoff_checklist =
528                    prompts::render_phase2_handoff_checklist(&handoff_template, &resolved.config)?;
529                let phase2_template =
530                    prompts::load_worker_phase2_handoff_prompt(&resolved.repo_root)?;
531                promptflow::build_phase2_handoff_prompt(
532                    &phase2_template,
533                    &base_prompt,
534                    &plan_text,
535                    &handoff_checklist,
536                    iteration_context,
537                    iteration_completion_block,
538                    &task_id,
539                    total_phases,
540                    &policy,
541                    &resolved.config,
542                )?
543            } else {
544                let completion_checklist = load_completion_checklist()?;
545                let phase2_template = prompts::load_worker_phase2_prompt(&resolved.repo_root)?;
546                promptflow::build_phase2_prompt(
547                    &phase2_template,
548                    &base_prompt,
549                    &plan_text,
550                    &completion_checklist,
551                    iteration_context,
552                    iteration_completion_block,
553                    &task_id,
554                    total_phases,
555                    &policy,
556                    &resolved.config,
557                )?
558            }
559        }
560        WorkerMode::Phase3 => {
561            let review_template = prompts::load_code_review_prompt(&resolved.repo_root)?;
562            let review_body = prompts::render_code_review_prompt(
563                &review_template,
564                &task_id,
565                project_type,
566                &resolved.config,
567            )?;
568            let completion_checklist = load_completion_checklist()?;
569            let phase3_template = prompts::load_worker_phase3_prompt(&resolved.repo_root)?;
570            let phase2_final_response =
571                load_phase2_final_response_for_phase3(&resolved.repo_root, &task_id);
572            promptflow::build_phase3_prompt(
573                &phase3_template,
574                &base_prompt,
575                &review_body,
576                &phase2_final_response,
577                &task_id,
578                &completion_checklist,
579                iteration_context,
580                iteration_completion_block,
581                phase3_completion_guidance,
582                total_phases,
583                &policy,
584                &resolved.config,
585            )?
586        }
587        WorkerMode::Single => {
588            let completion_checklist = load_completion_checklist()?;
589            let single_template = prompts::load_worker_single_phase_prompt(&resolved.repo_root)?;
590            promptflow::build_single_phase_prompt(
591                &single_template,
592                &base_prompt,
593                &completion_checklist,
594                iteration_context,
595                iteration_completion_block,
596                &task_id,
597                &policy,
598                &resolved.config,
599            )?
600        }
601    };
602
603    if !opts.explain {
604        return Ok(prompt);
605    }
606
607    let mut header = String::new();
608    header.push_str("# RALPH PROMPT PREVIEW (worker)\n\n");
609    header.push_str(&format!("- task_id: {}\n", task_id));
610    header.push_str(&format!(
611        "- mode: {}\n",
612        match opts.mode {
613            WorkerMode::Phase1 => "phase1",
614            WorkerMode::Phase2 => "phase2",
615            WorkerMode::Phase3 => "phase3",
616            WorkerMode::Single => "single",
617        }
618    ));
619    header.push_str(&format!(
620        "- repoprompt_plan_required: {}\n",
621        opts.repoprompt_plan_required
622    ));
623    header.push_str(&format!(
624        "- repoprompt_tool_injection: {}\n",
625        opts.repoprompt_tool_injection
626    ));
627    header.push_str(&format!(
628        "- iteration: {}/{}\n",
629        opts.iteration_index, opts.iterations
630    ));
631    header.push_str(&format!(
632        "- worker template source: {}\n",
633        worker_template_source(&resolved.repo_root)
634    ));
635    header.push_str("\n---\n\n");
636
637    Ok(format!("{header}{prompt}"))
638}
639
640pub fn build_scan_prompt(resolved: &config::Resolved, opts: ScanPromptOptions) -> Result<String> {
641    let scan_version = resolved
642        .config
643        .agent
644        .scan_prompt_version
645        .unwrap_or_default();
646    let template = prompts::load_scan_prompt(&resolved.repo_root, scan_version, opts.mode)?;
647    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
648    let rendered = prompts::render_scan_prompt(
649        &template,
650        &opts.focus,
651        opts.mode,
652        scan_version,
653        project_type,
654        &resolved.config,
655    )?;
656    let prompt =
657        prompts::wrap_with_repoprompt_requirement(&rendered, opts.repoprompt_tool_injection);
658    let prompt =
659        prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)?;
660
661    if !opts.explain {
662        return Ok(prompt);
663    }
664
665    let mut header = String::new();
666    header.push_str("# RALPH PROMPT PREVIEW (scan)\n\n");
667    header.push_str(&format!(
668        "- focus: {}\n",
669        if opts.focus.trim().is_empty() {
670            "(none)"
671        } else {
672            opts.focus.trim()
673        }
674    ));
675    header.push_str(&format!(
676        "- repoprompt_tool_injection: {}\n",
677        opts.repoprompt_tool_injection
678    ));
679    header.push_str(&format!(
680        "- scan template source: {}\n",
681        scan_template_source(&resolved.repo_root)
682    ));
683    header.push_str("\n---\n\n");
684
685    Ok(format!("{header}{prompt}"))
686}
687
688pub fn build_task_builder_prompt(
689    resolved: &config::Resolved,
690    opts: TaskBuilderPromptOptions,
691) -> Result<String> {
692    let request = opts.request.trim();
693    if request.is_empty() {
694        bail!("Missing request: task builder prompt preview requires a non-empty request.");
695    }
696
697    let template = prompts::load_task_builder_prompt(&resolved.repo_root)?;
698    let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
699    let rendered = prompts::render_task_builder_prompt(
700        &template,
701        request,
702        &opts.hint_tags,
703        &opts.hint_scope,
704        project_type,
705        &resolved.config,
706    )?;
707    let prompt =
708        prompts::wrap_with_repoprompt_requirement(&rendered, opts.repoprompt_tool_injection);
709    let prompt =
710        prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)?;
711
712    if !opts.explain {
713        return Ok(prompt);
714    }
715
716    let mut header = String::new();
717    header.push_str("# RALPH PROMPT PREVIEW (task builder)\n\n");
718    header.push_str(&format!("- request: {}\n", request));
719    header.push_str(&format!(
720        "- hint_tags: {}\n",
721        if opts.hint_tags.trim().is_empty() {
722            "(empty)"
723        } else {
724            opts.hint_tags.trim()
725        }
726    ));
727    header.push_str(&format!(
728        "- hint_scope: {}\n",
729        if opts.hint_scope.trim().is_empty() {
730            "(empty)"
731        } else {
732            opts.hint_scope.trim()
733        }
734    ));
735    header.push_str(&format!(
736        "- repoprompt_tool_injection: {}\n",
737        opts.repoprompt_tool_injection
738    ));
739    header.push_str(&format!(
740        "- task builder template source: {}\n",
741        task_builder_template_source(&resolved.repo_root)
742    ));
743    header.push_str("\n---\n\n");
744
745    Ok(format!("{header}{prompt}"))
746}
747
748#[cfg(test)]
749mod tests {
750    use super::resolve_worker_task_id;
751    use crate::config::Resolved;
752    use crate::contracts::{Config, QueueFile, Task, TaskPriority, TaskStatus};
753    use crate::queue;
754    use tempfile::TempDir;
755
756    fn make_task(id: &str, status: TaskStatus) -> Task {
757        Task {
758            id: id.to_string(),
759            title: format!("Task {id}"),
760            description: None,
761            status,
762            priority: TaskPriority::Medium,
763            tags: vec!["test".to_string()],
764            scope: vec!["crates/ralph".to_string()],
765            evidence: vec!["test".to_string()],
766            plan: vec!["plan".to_string()],
767            notes: vec![],
768            request: Some("request".to_string()),
769            agent: None,
770            created_at: Some("2026-01-18T00:00:00Z".to_string()),
771            updated_at: Some("2026-01-18T00:00:00Z".to_string()),
772            completed_at: None,
773            started_at: None,
774            scheduled_start: None,
775            depends_on: vec![],
776            blocks: vec![],
777            relates_to: vec![],
778            duplicates: None,
779            custom_fields: std::collections::HashMap::new(),
780            estimated_minutes: None,
781            actual_minutes: None,
782            parent_id: None,
783        }
784    }
785
786    fn make_resolved(temp: &TempDir) -> Resolved {
787        let repo_root = temp.path().to_path_buf();
788        let queue_path = repo_root.join("queue.json");
789        let done_path = repo_root.join("done.json");
790        Resolved {
791            config: Config::default(),
792            repo_root,
793            queue_path,
794            done_path,
795            id_prefix: "RQ".to_string(),
796            id_width: 4,
797            global_config_path: None,
798            project_config_path: None,
799        }
800    }
801
802    #[test]
803    fn resolve_worker_task_id_trims_explicit_task_id() {
804        let temp = TempDir::new().expect("tempdir");
805        let resolved = make_resolved(&temp);
806        let id = resolve_worker_task_id(&resolved, Some("  RQ-0009  ".to_string()))
807            .expect("should trim");
808        assert_eq!(id, "RQ-0009");
809    }
810
811    #[test]
812    fn resolve_worker_task_id_prefers_doing() {
813        let temp = TempDir::new().expect("tempdir");
814        let resolved = make_resolved(&temp);
815        let queue = QueueFile {
816            version: 1,
817            tasks: vec![
818                make_task("RQ-0001", TaskStatus::Todo),
819                make_task("RQ-0002", TaskStatus::Doing),
820            ],
821        };
822        queue::save_queue(&resolved.queue_path, &queue).expect("save queue");
823
824        let id = resolve_worker_task_id(&resolved, None).expect("should resolve doing");
825        assert_eq!(id, "RQ-0002");
826    }
827
828    #[test]
829    fn resolve_worker_task_id_returns_runnable_todo() {
830        let temp = TempDir::new().expect("tempdir");
831        let resolved = make_resolved(&temp);
832
833        let mut todo = make_task("RQ-0003", TaskStatus::Todo);
834        todo.depends_on = vec!["RQ-0002".to_string()];
835
836        let queue = QueueFile {
837            version: 1,
838            tasks: vec![todo],
839        };
840        let done = QueueFile {
841            version: 1,
842            tasks: vec![make_task("RQ-0002", TaskStatus::Done)],
843        };
844        queue::save_queue(&resolved.queue_path, &queue).expect("save queue");
845        queue::save_queue(&resolved.done_path, &done).expect("save done");
846
847        let id = resolve_worker_task_id(&resolved, None).expect("should resolve todo");
848        assert_eq!(id, "RQ-0003");
849    }
850}