1#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3#[serde(rename_all = "snake_case")]
4pub enum CompositionPolicy {
5 Autonomous,
6 #[default]
7 Propose,
8 Manual,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum SkillCreationRigor {
15 Generate,
16 Validate,
17 #[default]
18 Full,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum OutputValidationPolicy {
25 #[default]
26 Strict,
27 Sample,
28 Off,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AgentConfig {
33 pub name: String,
34 pub id: String,
35 #[serde(default = "default_workspace")]
36 pub workspace: PathBuf,
37 #[serde(default = "default_log_level")]
38 pub log_level: String,
39 #[serde(default = "default_true")]
40 pub delegation_enabled: bool,
41 #[serde(default = "default_min_decomposition_complexity")]
42 pub delegation_min_complexity: f64,
43 #[serde(default = "default_min_delegation_utility_margin")]
44 pub delegation_min_utility_margin: f64,
45 #[serde(default = "default_true")]
46 pub specialist_creation_requires_approval: bool,
47 #[serde(default = "default_autonomy_max_react_turns")]
48 pub autonomy_max_react_turns: usize,
49 #[serde(default = "default_autonomy_max_turn_duration_seconds")]
50 pub autonomy_max_turn_duration_seconds: u64,
51 #[serde(default)]
52 pub composition_policy: CompositionPolicy,
53 #[serde(default)]
54 pub skill_creation_rigor: SkillCreationRigor,
55 #[serde(default)]
56 pub output_validation_policy: OutputValidationPolicy,
57 #[serde(default = "default_output_validation_sample_rate")]
58 pub output_validation_sample_rate: f64,
59 #[serde(default = "default_max_output_retries")]
60 pub max_output_retries: u32,
61 #[serde(default = "default_retirement_threshold")]
62 pub retirement_success_threshold: f64,
63 #[serde(default = "default_retirement_min_delegations")]
64 pub retirement_min_delegations: i64,
65}
66
67fn default_workspace() -> PathBuf {
68 dirs_next().join("workspace")
69}
70
71pub fn default_workspace_path() -> PathBuf {
73 default_workspace()
74}
75
76fn default_log_level() -> String {
77 "info".into()
78}
79
80fn default_min_decomposition_complexity() -> f64 {
81 0.35
82}
83
84fn default_min_delegation_utility_margin() -> f64 {
85 0.15
86}
87
88fn default_autonomy_max_react_turns() -> usize {
89 10
90}
91
92fn default_autonomy_max_turn_duration_seconds() -> u64 {
93 90
94}
95
96fn default_output_validation_sample_rate() -> f64 {
97 0.25
98}
99
100fn default_max_output_retries() -> u32 {
101 2
102}
103
104fn default_retirement_threshold() -> f64 {
105 0.3
106}
107
108fn default_retirement_min_delegations() -> i64 {
109 5
110}
111
112impl Default for AgentConfig {
113 fn default() -> Self {
114 Self {
115 name: String::new(),
116 id: String::new(),
117 workspace: default_workspace(),
118 log_level: default_log_level(),
119 delegation_enabled: true,
120 delegation_min_complexity: default_min_decomposition_complexity(),
121 delegation_min_utility_margin: default_min_delegation_utility_margin(),
122 specialist_creation_requires_approval: true,
123 autonomy_max_react_turns: default_autonomy_max_react_turns(),
124 autonomy_max_turn_duration_seconds: default_autonomy_max_turn_duration_seconds(),
125 composition_policy: CompositionPolicy::default(),
126 skill_creation_rigor: SkillCreationRigor::default(),
127 output_validation_policy: OutputValidationPolicy::default(),
128 output_validation_sample_rate: default_output_validation_sample_rate(),
129 max_output_retries: default_max_output_retries(),
130 retirement_success_threshold: default_retirement_threshold(),
131 retirement_min_delegations: default_retirement_min_delegations(),
132 }
133 }
134}
135
136fn default_log_dir() -> PathBuf {
137 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
138 PathBuf::from(home).join(".roboticus").join("logs")
139}
140
141fn default_log_max_days() -> u32 {
142 7
143}
144
145fn dirs_next() -> PathBuf {
146 let home = home_dir();
147 let new_dir = home.join(".roboticus");
148 migrate_legacy_data_dir(&home, &new_dir);
149 new_dir
150}
151
152fn migrate_legacy_data_dir(home: &Path, new_dir: &Path) {
160 use std::sync::Once;
161 static MIGRATE_ONCE: Once = Once::new();
162
163 MIGRATE_ONCE.call_once(|| {
164 let legacy = home.join(".ironclad");
165
166 let sentinel = new_dir.join(".migration_pending_delete");
168 if sentinel.exists()
169 && let Ok(source) = std::fs::read_to_string(&sentinel)
170 {
171 let source_path = Path::new(source.trim());
172 if source_path.exists() {
173 match std::fs::remove_dir_all(source_path) {
174 Ok(()) => {
175 eprintln!("[roboticus] Completed deferred cleanup of {}", source_path.display());
176 let _ = std::fs::remove_file(&sentinel);
177 }
178 Err(e) => {
179 eprintln!(
180 "[roboticus] Still cannot remove {}: {e} — will retry on next run",
181 source_path.display()
182 );
183 }
184 }
185 } else {
186 let _ = std::fs::remove_file(&sentinel);
188 }
189 }
190
191 if !legacy.exists() {
192 return;
193 }
194 if new_dir.exists() {
195 eprintln!(
196 "[roboticus] Both ~/.ironclad and ~/.roboticus exist; skipping automatic migration. \
197 Merge manually and remove ~/.ironclad to silence this warning."
198 );
199 return;
200 }
201
202 if std::fs::rename(&legacy, new_dir).is_err() {
204 if let Err(e) = copy_dir_recursive(&legacy, new_dir) {
206 eprintln!("[roboticus] failed to copy ~/.ironclad to ~/.roboticus: {e}");
207 return;
208 }
209 if let Err(e) = std::fs::remove_dir_all(&legacy) {
210 eprintln!(
211 "[roboticus] copied ~/.ironclad to ~/.roboticus but could not remove the original: {e}"
212 );
213 let _ = std::fs::write(&sentinel, legacy.to_string_lossy().as_ref());
215 }
216 }
217 eprintln!("[roboticus] Migrated data directory from ~/.ironclad to ~/.roboticus");
218
219 let old_config = new_dir.join("ironclad.toml");
221 let new_config = new_dir.join("roboticus.toml");
222 if old_config.exists() && !new_config.exists() {
223 if let Err(e) = std::fs::rename(&old_config, &new_config) {
224 eprintln!("[roboticus] failed to rename ironclad.toml to roboticus.toml: {e}");
225 } else {
226 eprintln!("[roboticus] Renamed ironclad.toml → roboticus.toml");
227 }
228 }
229
230 rewrite_all_toml_files(new_dir);
232 });
233}
234
235pub fn rewrite_all_toml_files(dir: &Path) {
237 let walker = match std::fs::read_dir(dir) {
238 Ok(w) => w,
239 Err(_) => return,
240 };
241 for entry in walker.flatten() {
242 let path = entry.path();
243 if path.is_dir() {
244 rewrite_all_toml_files(&path);
245 } else if path.extension().and_then(|e| e.to_str()) == Some("toml") {
246 rewrite_legacy_paths_in_config(&path);
247 }
248 }
249}
250
251pub fn rewrite_legacy_paths_in_config(path: &Path) {
255 let Ok(content) = std::fs::read_to_string(path) else {
256 return;
257 };
258 let rewritten = content
259 .replace("/.ironclad/", "/.roboticus/")
261 .replace("\\.ironclad\\", "\\.roboticus\\")
262 .replace("\\\\.ironclad\\\\", "\\\\.roboticus\\\\")
263 .replace("/.ironclad\"", "/.roboticus\"")
265 .replace("/.ironclad'", "/.roboticus'")
266 .replace("\\.ironclad\"", "\\.roboticus\"")
267 .replace("\\.ironclad'", "\\.roboticus'")
268 .replace("\\\\.ironclad\"", "\\\\.roboticus\"")
269 .replace("\\\\.ironclad'", "\\\\.roboticus'");
270 if rewritten != content {
271 if let Err(e) = std::fs::write(path, &rewritten) {
272 eprintln!(
273 "[roboticus] failed to rewrite legacy paths in {}: {e}",
274 path.display()
275 );
276 } else {
277 eprintln!(
278 "[roboticus] Rewrote legacy paths in {}",
279 path.display()
280 );
281 }
282 }
283}
284
285fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
287 std::fs::create_dir_all(dst)?;
288 for entry in std::fs::read_dir(src)? {
289 let entry = entry?;
290 let ty = entry.file_type()?;
291 let dest_path = dst.join(entry.file_name());
292 if ty.is_dir() {
293 copy_dir_recursive(&entry.path(), &dest_path)?;
294 } else if ty.is_file() {
295 std::fs::copy(entry.path(), &dest_path)?;
296 }
297 }
299 Ok(())
300}
301
302pub fn home_dir() -> PathBuf {
305 std::env::var("HOME")
306 .or_else(|_| std::env::var("USERPROFILE"))
307 .map(PathBuf::from)
308 .unwrap_or_else(|_| std::env::temp_dir())
309}
310
311pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
319 if let Some(p) = explicit {
320 return Some(expand_tilde(Path::new(p)));
321 }
322
323 if let Ok(legacy_env) = std::env::var("IRONCLAD_CONFIG") {
325 eprintln!(
326 "[roboticus] IRONCLAD_CONFIG is deprecated; use ROBOTICUS_CONFIG instead. \
327 Falling back to: {legacy_env}"
328 );
329 return Some(expand_tilde(Path::new(&legacy_env)));
330 }
331
332 let home = home_dir();
334 let roboticus_dir = home.join(".roboticus");
335 migrate_legacy_data_dir(&home, &roboticus_dir);
336
337 let home_config = roboticus_dir.join("roboticus.toml");
338 if home_config.exists() {
339 return Some(home_config);
340 }
341 let cwd_config = PathBuf::from("roboticus.toml");
342 if cwd_config.exists() {
343 return Some(cwd_config);
344 }
345
346 let legacy_home = roboticus_dir.join("ironclad.toml");
348 if legacy_home.exists() {
349 tracing::info!("Using legacy config file: {}", legacy_home.display());
350 return Some(legacy_home);
351 }
352 let legacy_cwd = PathBuf::from("ironclad.toml");
353 if legacy_cwd.exists() {
354 tracing::info!("Using legacy config file: ironclad.toml in current directory");
355 return Some(legacy_cwd);
356 }
357 None
358}
359
360fn expand_tilde(path: &Path) -> PathBuf {
362 if let Ok(stripped) = path.strip_prefix("~") {
363 home_dir().join(stripped)
364 } else {
365 path.to_path_buf()
366 }
367}