1use crate::contracts::{
23 ClaudePermissionMode, Model, ReasoningEffort, Runner, RunnerCliOptionsPatch,
24};
25use crate::{config, runner};
26use anyhow::{Context, Result, bail};
27use std::io::{IsTerminal, Read};
28use std::path::PathBuf;
29
30mod build;
31mod decompose;
32mod refactor;
33mod update;
34
35pub use decompose::{
36 DecompositionAttachTarget, DecompositionChildPolicy, DecompositionPlan, DecompositionPreview,
37 DecompositionSource, PlannedNode, TaskDecomposeOptions, TaskDecomposeWriteResult,
38 plan_task_decomposition, write_task_decomposition,
39};
40
41#[derive(Clone, Copy, Debug)]
43pub enum BatchMode {
44 Auto,
46 Never,
48 Aggressive,
50}
51
52impl From<crate::cli::task::BatchMode> for BatchMode {
53 fn from(mode: crate::cli::task::BatchMode) -> Self {
54 match mode {
55 crate::cli::task::BatchMode::Auto => BatchMode::Auto,
56 crate::cli::task::BatchMode::Never => BatchMode::Never,
57 crate::cli::task::BatchMode::Aggressive => BatchMode::Aggressive,
58 }
59 }
60}
61
62pub struct TaskBuildRefactorOptions {
64 pub threshold: usize,
65 pub path: Option<PathBuf>,
66 pub dry_run: bool,
67 pub batch: BatchMode,
68 pub extra_tags: String,
69 pub runner_override: Option<Runner>,
70 pub model_override: Option<Model>,
71 pub reasoning_effort_override: Option<ReasoningEffort>,
72 pub runner_cli_overrides: RunnerCliOptionsPatch,
73 pub force: bool,
74 pub repoprompt_tool_injection: bool,
75}
76
77pub struct TaskBuildOptions {
79 pub request: String,
80 pub hint_tags: String,
81 pub hint_scope: String,
82 pub runner_override: Option<Runner>,
83 pub model_override: Option<Model>,
84 pub reasoning_effort_override: Option<ReasoningEffort>,
85 pub runner_cli_overrides: RunnerCliOptionsPatch,
86 pub force: bool,
87 pub repoprompt_tool_injection: bool,
88 pub template_hint: Option<String>,
90 pub template_target: Option<String>,
92 pub strict_templates: bool,
94 pub estimated_minutes: Option<u32>,
96}
97
98pub struct TaskUpdateSettings {
100 pub fields: String,
101 pub runner_override: Option<Runner>,
102 pub model_override: Option<Model>,
103 pub reasoning_effort_override: Option<ReasoningEffort>,
104 pub runner_cli_overrides: RunnerCliOptionsPatch,
105 pub force: bool,
106 pub repoprompt_tool_injection: bool,
107 pub dry_run: bool,
108}
109
110#[derive(Debug, Clone)]
111pub(crate) struct TaskRunnerSettings {
112 pub(crate) runner: Runner,
113 pub(crate) model: Model,
114 pub(crate) reasoning_effort: Option<ReasoningEffort>,
115 pub(crate) runner_cli: runner::ResolvedRunnerCliOptions,
116 pub(crate) permission_mode: Option<ClaudePermissionMode>,
117}
118
119pub(crate) fn resolve_task_runner_settings(
120 resolved: &config::Resolved,
121 runner_override: Option<Runner>,
122 model_override: Option<Model>,
123 reasoning_effort_override: Option<ReasoningEffort>,
124 runner_cli_overrides: &RunnerCliOptionsPatch,
125) -> Result<TaskRunnerSettings> {
126 let settings = runner::resolve_agent_settings(
127 runner_override,
128 model_override,
129 reasoning_effort_override,
130 runner_cli_overrides,
131 None,
132 &resolved.config.agent,
133 )?;
134
135 Ok(TaskRunnerSettings {
136 runner: settings.runner,
137 model: settings.model,
138 reasoning_effort: settings.reasoning_effort,
139 runner_cli: settings.runner_cli,
140 permission_mode: resolved.config.agent.claude_permission_mode,
141 })
142}
143
144pub(crate) fn resolve_task_build_settings(
145 resolved: &config::Resolved,
146 opts: &TaskBuildOptions,
147) -> Result<TaskRunnerSettings> {
148 resolve_task_runner_settings(
149 resolved,
150 opts.runner_override.clone(),
151 opts.model_override.clone(),
152 opts.reasoning_effort_override,
153 &opts.runner_cli_overrides,
154 )
155}
156
157pub(crate) fn resolve_task_update_settings(
158 resolved: &config::Resolved,
159 settings: &TaskUpdateSettings,
160) -> Result<TaskRunnerSettings> {
161 resolve_task_runner_settings(
162 resolved,
163 settings.runner_override.clone(),
164 settings.model_override.clone(),
165 settings.reasoning_effort_override,
166 &settings.runner_cli_overrides,
167 )
168}
169
170pub fn read_request_from_args_or_reader(
171 args: &[String],
172 stdin_is_terminal: bool,
173 mut reader: impl Read,
174) -> Result<String> {
175 if !args.is_empty() {
176 let joined = args.join(" ");
177 let trimmed = joined.trim();
178 if trimmed.is_empty() {
179 bail!(
180 "Missing request: task requires a request description. Pass arguments or pipe input to the command."
181 );
182 }
183 return Ok(trimmed.to_string());
184 }
185
186 if stdin_is_terminal {
187 bail!(
188 "Missing request: task requires a request description. Pass arguments or pipe input to the command."
189 );
190 }
191
192 let mut buf = String::new();
193 reader.read_to_string(&mut buf).context("read stdin")?;
194 let trimmed = buf.trim();
195 if trimmed.is_empty() {
196 bail!(
197 "Missing request: task requires a request description (pass arguments or pipe input to the command)."
198 );
199 }
200 Ok(trimmed.to_string())
201}
202
203pub fn read_request_from_args_or_stdin(args: &[String]) -> Result<String> {
205 let stdin = std::io::stdin();
206 let stdin_is_terminal = stdin.is_terminal();
207 let handle = stdin.lock();
208 read_request_from_args_or_reader(args, stdin_is_terminal, handle)
209}
210
211pub fn compare_task_fields(before: &str, after: &str) -> Result<Vec<String>> {
212 let before_value: serde_json::Value = serde_json::from_str(before)?;
213 let after_value: serde_json::Value = serde_json::from_str(after)?;
214
215 if let (Some(before_obj), Some(after_obj)) = (before_value.as_object(), after_value.as_object())
216 {
217 let mut changed = Vec::new();
218 for (key, after_val) in after_obj {
219 if let Some(before_val) = before_obj.get(key) {
220 if before_val != after_val {
221 changed.push(key.clone());
222 }
223 } else {
224 changed.push(key.clone());
225 }
226 }
227 Ok(changed)
228 } else {
229 Ok(vec!["task".to_string()])
230 }
231}
232
233pub use build::{build_task, build_task_without_lock};
235pub use refactor::build_refactor_tasks;
236pub use update::{update_all_tasks, update_task, update_task_without_lock};
237
238#[cfg(test)]
239mod tests {
240 use super::{
241 TaskBuildOptions, TaskUpdateSettings, read_request_from_args_or_reader,
242 resolve_task_build_settings, resolve_task_update_settings,
243 };
244 use crate::config;
245 use crate::contracts::{
246 ClaudePermissionMode, Config, RunnerApprovalMode, RunnerCliConfigRoot,
247 RunnerCliOptionsPatch, RunnerOutputFormat, RunnerPlanMode, RunnerSandboxMode,
248 RunnerVerbosity, UnsupportedOptionPolicy,
249 };
250 use std::collections::BTreeMap;
251 use std::io::Cursor;
252 use std::path::PathBuf;
253 use tempfile::TempDir;
254
255 fn resolved_with_config(config: Config) -> (config::Resolved, TempDir) {
256 let dir = TempDir::new().expect("temp dir");
257 let repo_root = dir.path().to_path_buf();
258 let queue_rel = config
259 .queue
260 .file
261 .clone()
262 .unwrap_or_else(|| PathBuf::from(".ralph/queue.json"));
263 let done_rel = config
264 .queue
265 .done_file
266 .clone()
267 .unwrap_or_else(|| PathBuf::from(".ralph/done.json"));
268 let id_prefix = config
269 .queue
270 .id_prefix
271 .clone()
272 .unwrap_or_else(|| "RQ".to_string());
273 let id_width = config.queue.id_width.unwrap_or(4) as usize;
274
275 (
276 config::Resolved {
277 config,
278 repo_root: repo_root.clone(),
279 queue_path: repo_root.join(queue_rel),
280 done_path: repo_root.join(done_rel),
281 id_prefix,
282 id_width,
283 global_config_path: None,
284 project_config_path: Some(repo_root.join(".ralph/config.json")),
285 },
286 dir,
287 )
288 }
289
290 fn build_opts() -> TaskBuildOptions {
291 TaskBuildOptions {
292 request: "request".to_string(),
293 hint_tags: String::new(),
294 hint_scope: String::new(),
295 runner_override: None,
296 model_override: None,
297 reasoning_effort_override: None,
298 runner_cli_overrides: RunnerCliOptionsPatch::default(),
299 force: false,
300 repoprompt_tool_injection: false,
301 template_hint: None,
302 template_target: None,
303 strict_templates: false,
304 estimated_minutes: None,
305 }
306 }
307
308 fn update_settings() -> TaskUpdateSettings {
309 TaskUpdateSettings {
310 fields: "scope".to_string(),
311 runner_override: None,
312 model_override: None,
313 reasoning_effort_override: None,
314 runner_cli_overrides: RunnerCliOptionsPatch::default(),
315 force: false,
316 repoprompt_tool_injection: false,
317 dry_run: false,
318 }
319 }
320
321 #[test]
322 fn read_request_from_args_or_reader_rejects_empty_args_on_terminal() {
323 let args: Vec<String> = vec![];
324 let reader = Cursor::new("");
325 let err = read_request_from_args_or_reader(&args, true, reader).unwrap_err();
326 let message = err.to_string();
327 assert!(message.contains("Missing request"));
328 assert!(message.contains("Pass arguments"));
329 }
330
331 #[test]
332 fn read_request_from_args_or_reader_reads_piped_input() {
333 let args: Vec<String> = vec![];
334 let reader = Cursor::new(" hello world ");
335 let value = read_request_from_args_or_reader(&args, false, reader).unwrap();
336 assert_eq!(value, "hello world");
337 }
338
339 #[test]
340 fn read_request_from_args_or_reader_rejects_empty_piped_input() {
341 let args: Vec<String> = vec![];
342 let reader = Cursor::new(" ");
343 let err = read_request_from_args_or_reader(&args, false, reader).unwrap_err();
344 assert!(err.to_string().contains("Missing request"));
345 }
346
347 #[test]
348 fn task_build_respects_config_permission_mode_when_approval_default() {
349 let mut config = Config::default();
350 config.agent.claude_permission_mode = Some(ClaudePermissionMode::AcceptEdits);
351 config.agent.runner_cli = Some(RunnerCliConfigRoot {
352 defaults: RunnerCliOptionsPatch {
353 output_format: Some(RunnerOutputFormat::StreamJson),
354 verbosity: Some(RunnerVerbosity::Normal),
355 approval_mode: Some(RunnerApprovalMode::Default),
356 sandbox: Some(RunnerSandboxMode::Default),
357 plan_mode: Some(RunnerPlanMode::Default),
358 unsupported_option_policy: Some(UnsupportedOptionPolicy::Warn),
359 },
360 runners: BTreeMap::new(),
361 });
362
363 let (resolved, _dir) = resolved_with_config(config);
364 let settings = resolve_task_build_settings(&resolved, &build_opts()).expect("settings");
365 let effective = settings
366 .runner_cli
367 .effective_claude_permission_mode(settings.permission_mode);
368 assert_eq!(effective, Some(ClaudePermissionMode::AcceptEdits));
369 }
370
371 #[test]
372 fn task_update_cli_override_yolo_bypasses_permission_mode() {
373 let mut config = Config::default();
374 config.agent.claude_permission_mode = Some(ClaudePermissionMode::AcceptEdits);
375 config.agent.runner_cli = Some(RunnerCliConfigRoot {
376 defaults: RunnerCliOptionsPatch {
377 output_format: Some(RunnerOutputFormat::StreamJson),
378 verbosity: Some(RunnerVerbosity::Normal),
379 approval_mode: Some(RunnerApprovalMode::Default),
380 sandbox: Some(RunnerSandboxMode::Default),
381 plan_mode: Some(RunnerPlanMode::Default),
382 unsupported_option_policy: Some(UnsupportedOptionPolicy::Warn),
383 },
384 runners: BTreeMap::new(),
385 });
386
387 let mut settings = update_settings();
388 settings.runner_cli_overrides = RunnerCliOptionsPatch {
389 approval_mode: Some(RunnerApprovalMode::Yolo),
390 ..RunnerCliOptionsPatch::default()
391 };
392
393 let (resolved, _dir) = resolved_with_config(config);
394 let runner_settings = resolve_task_update_settings(&resolved, &settings).expect("settings");
395 let effective = runner_settings
396 .runner_cli
397 .effective_claude_permission_mode(runner_settings.permission_mode);
398 assert_eq!(effective, Some(ClaudePermissionMode::BypassPermissions));
399 }
400
401 #[test]
402 fn task_build_fails_fast_when_safe_approval_requires_prompt() {
403 let mut config = Config::default();
404 config.agent.runner_cli = Some(RunnerCliConfigRoot {
405 defaults: RunnerCliOptionsPatch {
406 output_format: Some(RunnerOutputFormat::StreamJson),
407 approval_mode: Some(RunnerApprovalMode::Safe),
408 unsupported_option_policy: Some(UnsupportedOptionPolicy::Error),
409 ..RunnerCliOptionsPatch::default()
410 },
411 runners: BTreeMap::new(),
412 });
413
414 let (resolved, _dir) = resolved_with_config(config);
415 let err = resolve_task_build_settings(&resolved, &build_opts()).expect_err("error");
416 assert!(err.to_string().contains("approval_mode=safe"));
417 }
418
419 #[test]
420 fn task_update_fails_fast_when_safe_approval_requires_prompt() {
421 let mut config = Config::default();
422 config.agent.runner_cli = Some(RunnerCliConfigRoot {
423 defaults: RunnerCliOptionsPatch {
424 output_format: Some(RunnerOutputFormat::StreamJson),
425 approval_mode: Some(RunnerApprovalMode::Safe),
426 unsupported_option_policy: Some(UnsupportedOptionPolicy::Error),
427 ..RunnerCliOptionsPatch::default()
428 },
429 runners: BTreeMap::new(),
430 });
431
432 let (resolved, _dir) = resolved_with_config(config);
433 let err = resolve_task_update_settings(&resolved, &update_settings()).expect_err("error");
434 assert!(err.to_string().contains("approval_mode=safe"));
435 }
436}