1use crate::a2a::types::{Part, TaskState};
2use crate::audit::{self, AuditCategory, AuditLog, AuditOutcome};
3use crate::bus::s3_sink::{BusS3Sink, BusS3SinkConfig};
4use crate::bus::{AgentBus, BusHandle, BusMessage};
5use crate::cli::{ForageArgs, RunArgs};
6use crate::okr::{
7 KeyResult, KrOutcome, KrOutcomeType, Okr, OkrRepository, OkrRun, OkrRunStatus, OkrStatus,
8};
9use crate::provider::ProviderRegistry;
10use crate::swarm::{DecompositionStrategy, ExecutionMode, SwarmConfig, SwarmExecutor};
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use serde::Serialize;
14use serde_json::json;
15use std::cmp::Ordering;
16use std::collections::{BTreeSet, HashSet};
17use std::process::Command;
18use std::time::Duration;
19use uuid::Uuid;
20
21#[derive(Debug, Clone, Serialize)]
22struct ForageOpportunity {
23 score: f64,
24 okr_id: Uuid,
25 okr_title: String,
26 okr_status: OkrStatus,
27 key_result_id: Uuid,
28 key_result_title: String,
29 progress: f64,
30 remaining: f64,
31 target_date: Option<DateTime<Utc>>,
32 moonshot_alignment: f64,
33 moonshot_hits: Vec<String>,
34 prompt: String,
35}
36
37#[derive(Debug, Clone, Default)]
38struct MoonshotRubric {
39 goals: Vec<String>,
40 required: bool,
41 min_alignment: f64,
42}
43
44#[derive(Debug, Clone)]
45struct ExecutionOutcome {
46 detail: String,
47 changed_files: Vec<String>,
48 quality_gates_passed: bool,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct ForageSelectionSummary {
53 pub okr_title: String,
54 pub key_result_title: String,
55 pub score: f64,
56 pub progress: f64,
57 pub remaining: f64,
58 pub moonshot_alignment: f64,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct ForageRunSummary {
63 pub cycles_completed: usize,
64 pub execute_requested: bool,
65 pub execution_engine: String,
66 pub total_selected: usize,
67 pub total_executed: usize,
68 pub total_execution_failures: usize,
69 pub last_cycle_selected: Vec<ForageSelectionSummary>,
70}
71
72impl ForageRunSummary {
73 fn summarize_selection(selected: &[ForageOpportunity]) -> Vec<ForageSelectionSummary> {
74 selected
75 .iter()
76 .map(|item| ForageSelectionSummary {
77 okr_title: item.okr_title.clone(),
78 key_result_title: item.key_result_title.clone(),
79 score: item.score,
80 progress: item.progress,
81 remaining: item.remaining,
82 moonshot_alignment: item.moonshot_alignment,
83 })
84 .collect()
85 }
86
87 pub fn render_text(&self) -> String {
88 let mut lines = vec![format!(
89 "Forage completed {} cycle(s); selected {} opportunity(s) total.",
90 self.cycles_completed, self.total_selected
91 )];
92
93 if self.last_cycle_selected.is_empty() {
94 lines.push("No forage opportunities were selected in the final cycle.".to_string());
95 } else {
96 lines.push(format!(
97 "Final cycle selected {} opportunity(s):",
98 self.last_cycle_selected.len()
99 ));
100 for (idx, item) in self.last_cycle_selected.iter().take(5).enumerate() {
101 lines.push(format!(
102 "{}. {} -> {} (score {:.3}, remaining {:.1}%)",
103 idx + 1,
104 item.okr_title,
105 item.key_result_title,
106 item.score,
107 item.remaining * 100.0
108 ));
109 }
110 if self.last_cycle_selected.len() > 5 {
111 lines.push(format!(
112 "... and {} more final-cycle opportunities",
113 self.last_cycle_selected.len() - 5
114 ));
115 }
116 }
117
118 if self.execute_requested {
119 lines.push(format!(
120 "Execution engine: {}. Attempted {} execution(s) with {} failure(s).",
121 self.execution_engine, self.total_executed, self.total_execution_failures
122 ));
123 }
124
125 lines.join("\n")
126 }
127}
128
129pub async fn execute(args: ForageArgs) -> Result<()> {
130 execute_with_summary(args).await.map(|_| ())
131}
132
133pub async fn execute_with_summary(args: ForageArgs) -> Result<ForageRunSummary> {
134 ensure_audit_log_initialized().await;
135 let repo = OkrRepository::from_config().await?;
136 let moonshot_rubric = load_moonshot_rubric(&args).await?;
137 if args.execute {
138 seed_initial_okr_if_empty(&repo, &moonshot_rubric).await?;
139 }
140 let bus = AgentBus::new().into_arc();
141 let require_s3 = !args.no_s3;
143 let mut s3_sync_handle = if require_s3 {
144 Some(start_required_bus_s3_sink(bus.clone()).await?)
145 } else {
146 tracing::info!("S3 archival disabled (--no-s3); running forage in local-only mode");
147 None
148 };
149 let forage_id = "forage-runtime";
150 let bus_handle = bus.handle(forage_id);
151 let mut observer = bus.handle("forage-observer");
152 let _ = bus_handle.announce_ready(vec![
153 "okr-governance".to_string(),
154 "business-prioritization".to_string(),
155 "autonomous-forage".to_string(),
156 ]);
157
158 log_audit(
159 AuditCategory::Cognition,
160 "forage.start",
161 AuditOutcome::Success,
162 Some(json!({
163 "top": args.top,
164 "loop_mode": args.loop_mode,
165 "interval_secs": args.interval_secs,
166 "max_cycles": args.max_cycles,
167 "execute": args.execute,
168 "execution_engine": args.execution_engine,
169 "run_timeout_secs": args.run_timeout_secs,
170 "fail_fast": args.fail_fast,
171 "swarm_strategy": args.swarm_strategy,
172 "swarm_max_subagents": args.swarm_max_subagents,
173 "swarm_max_steps": args.swarm_max_steps,
174 "swarm_subagent_timeout_secs": args.swarm_subagent_timeout_secs,
175 "model": args.model,
176 "moonshot_goals": moonshot_rubric.goals.clone(),
177 "moonshot_required": moonshot_rubric.required,
178 "moonshot_min_alignment": moonshot_rubric.min_alignment,
179 })),
180 None,
181 None,
182 )
183 .await;
184
185 let top = args.top.clamp(1, 50);
186 let interval_secs = args.interval_secs.clamp(5, 86_400);
187 let mut cycle: usize = 0;
188 let mut summary = ForageRunSummary {
189 cycles_completed: 0,
190 execute_requested: args.execute,
191 execution_engine: args.execution_engine.clone(),
192 total_selected: 0,
193 total_executed: 0,
194 total_execution_failures: 0,
195 last_cycle_selected: Vec::new(),
196 };
197
198 loop {
199 if require_s3 {
201 ensure_s3_sync_alive(&mut s3_sync_handle).await?;
202 }
203 cycle = cycle.saturating_add(1);
204 let cycle_task_id = format!("forage-cycle-{cycle}");
205 let _ = bus_handle.send_task_update(
206 &cycle_task_id,
207 TaskState::Working,
208 Some("scanning OKR opportunities".to_string()),
209 );
210 let mut opportunities = build_opportunities(&repo, &moonshot_rubric).await?;
211 if args.execute && opportunities.is_empty() {
212 if let Some(seed_okr_id) =
213 seed_moonshot_okr_if_no_opportunities(&repo, &moonshot_rubric).await?
214 {
215 tracing::info!(
216 okr_id = %seed_okr_id,
217 moonshot_count = moonshot_rubric.goals.len(),
218 "Seeded moonshot-derived OKR because forage found no opportunities"
219 );
220 opportunities = build_opportunities(&repo, &moonshot_rubric).await?;
221 }
222 }
223 let selected: Vec<ForageOpportunity> = opportunities.into_iter().take(top).collect();
224 let _ = bus_handle.send(
225 format!("forage.{cycle_task_id}.summary"),
226 BusMessage::SharedResult {
227 key: format!("forage/cycle/{cycle}/summary"),
228 value: json!({
229 "cycle": cycle,
230 "selected": selected.len(),
231 "top": top,
232 }),
233 tags: vec![
234 "forage".to_string(),
235 "okr".to_string(),
236 "summary".to_string(),
237 ],
238 },
239 );
240 let _ = bus_handle.send_to_agent(
241 "user",
242 vec![Part::Text {
243 text: format!(
244 "Forage cycle {cycle}: selected {} opportunities from OKR governance queue.",
245 selected.len()
246 ),
247 }],
248 );
249
250 if args.json {
251 #[derive(Serialize)]
252 struct CycleOutput {
253 cycle: usize,
254 selected: Vec<ForageOpportunity>,
255 }
256 let payload = CycleOutput {
257 cycle,
258 selected: selected.clone(),
259 };
260 println!("{}", serde_json::to_string_pretty(&payload)?);
261 } else {
262 println!("\n=== Forage Cycle {} ===", cycle);
263 if selected.is_empty() {
264 println!(
265 "No OKR opportunities found (active/draft/on_hold with remaining KR work)."
266 );
267 } else {
268 for (idx, item) in selected.iter().enumerate() {
269 println!(
270 "\n{}. [{}] {}",
271 idx + 1,
272 item.okr_status_label(),
273 item.okr_title
274 );
275 println!(
276 " KR: {} ({:.1}% remaining, score {:.3})",
277 item.key_result_title,
278 item.remaining * 100.0,
279 item.score
280 );
281 println!(
282 " Progress: {:.1}% complete",
283 item.progress.clamp(0.0, 1.0) * 100.0
284 );
285 if !moonshot_rubric.goals.is_empty() {
286 let hits = if item.moonshot_hits.is_empty() {
287 "none".to_string()
288 } else {
289 item.moonshot_hits.join(" | ")
290 };
291 println!(
292 " Moonshot alignment: {:.1}% (hits: {})",
293 item.moonshot_alignment * 100.0,
294 hits
295 );
296 }
297 }
298 }
299 }
300 summary.cycles_completed = cycle;
301 summary.total_selected = summary.total_selected.saturating_add(selected.len());
302 summary.last_cycle_selected = ForageRunSummary::summarize_selection(&selected);
303 log_audit(
304 AuditCategory::Cognition,
305 "forage.cycle",
306 AuditOutcome::Success,
307 Some(json!({
308 "cycle": cycle,
309 "selected": selected.len(),
310 "top": top,
311 })),
312 None,
313 None,
314 )
315 .await;
316 flush_bus_observer(&mut observer, cycle, args.json);
317
318 if args.execute && !selected.is_empty() {
319 for item in &selected {
320 tracing::info!(
321 okr_id = %item.okr_id,
322 key_result_id = %item.key_result_id,
323 engine = %args.execution_engine,
324 score = item.score,
325 "Executing forage opportunity"
326 );
327
328 let exec_task_id = format!("forage-okr-{}-kr-{}", item.okr_id, item.key_result_id);
329 let _ = bus_handle.send_task_update(
330 &exec_task_id,
331 TaskState::Working,
332 Some(format!(
333 "executing ({}) opportunity '{}' for KR '{}'",
334 args.execution_engine, item.okr_title, item.key_result_title
335 )),
336 );
337 summary.total_executed = summary.total_executed.saturating_add(1);
338 match execute_opportunity(item, &args).await {
339 Ok(execution_outcome) => {
340 if let Err(err) = record_execution_success_to_okr(
341 &repo,
342 item,
343 &args,
344 &execution_outcome,
345 cycle,
346 )
347 .await
348 {
349 tracing::warn!(
350 okr_id = %item.okr_id,
351 key_result_id = %item.key_result_id,
352 error = %err,
353 "Failed to persist forage execution progress to OKR"
354 );
355 }
356 let _ = bus_handle.send_task_update(
357 &exec_task_id,
358 TaskState::Completed,
359 Some(execution_outcome.detail.clone()),
360 );
361 log_audit(
362 AuditCategory::Cognition,
363 "forage.execute",
364 AuditOutcome::Success,
365 Some(json!({
366 "engine": args.execution_engine,
367 "score": item.score,
368 "okr_title": item.okr_title,
369 "key_result_title": item.key_result_title,
370 "detail": execution_outcome.detail,
371 "changed_files": execution_outcome.changed_files,
372 })),
373 Some(item.okr_id),
374 None,
375 )
376 .await;
377 }
378 Err(err) => {
379 summary.total_execution_failures =
380 summary.total_execution_failures.saturating_add(1);
381 let error_message = format!("{err:#}");
382 let _ = bus_handle.send_task_update(
383 &exec_task_id,
384 TaskState::Failed,
385 Some(format!("execution failed: {error_message}")),
386 );
387 log_audit(
388 AuditCategory::Cognition,
389 "forage.execute",
390 AuditOutcome::Failure,
391 Some(json!({
392 "engine": args.execution_engine,
393 "score": item.score,
394 "okr_title": item.okr_title,
395 "key_result_title": item.key_result_title,
396 "error": error_message,
397 })),
398 Some(item.okr_id),
399 None,
400 )
401 .await;
402 if args.fail_fast {
403 return Err(err);
404 }
405 }
406 }
407 }
408 }
409
410 let keep_running = args.loop_mode && (args.max_cycles == 0 || cycle < args.max_cycles);
411 let _ = bus_handle.send_task_update(
412 &cycle_task_id,
413 TaskState::Completed,
414 Some(format!("cycle complete (keep_running={keep_running})")),
415 );
416
417 if !keep_running {
418 break;
419 }
420
421 tracing::info!(
422 cycle,
423 interval_secs,
424 "Forage loop sleeping before next cycle"
425 );
426 let _ = bus_handle.send_task_update(
427 &cycle_task_id,
428 TaskState::Submitted,
429 Some(format!("sleeping {interval_secs}s before next cycle")),
430 );
431 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
432 }
433 let _ = bus_handle.announce_shutdown();
434 log_audit(
435 AuditCategory::Cognition,
436 "forage.stop",
437 AuditOutcome::Success,
438 Some(json!({ "cycles": cycle })),
439 None,
440 None,
441 )
442 .await;
443
444 Ok(summary)
445}
446
447async fn seed_default_okr_if_empty(repo: &OkrRepository) -> Result<()> {
448 let existing = repo.list_okrs().await?;
449 if !existing.is_empty() {
450 return Ok(());
451 }
452
453 let mut okr = Okr::new(
454 "Mission: Autonomous Business-Aligned Execution",
455 "Autonomously execute concrete, behavior-preserving code changes that align to business goals and produce measurable progress.",
456 );
457 okr.status = OkrStatus::Active;
458 let okr_id = okr.id;
459 okr.add_key_result(KeyResult::new(okr_id, "Key Result 1", 100.0, "%"));
460 okr.add_key_result(KeyResult::new(
461 okr_id,
462 "Team produces actionable handoff",
463 100.0,
464 "%",
465 ));
466 okr.add_key_result(KeyResult::new(okr_id, "No critical errors", 100.0, "%"));
467
468 let _ = repo.create_okr(okr).await?;
469 tracing::info!(okr_id = %okr_id, "Seeded default OKR for forage execution");
470 Ok(())
471}
472
473async fn seed_initial_okr_if_empty(repo: &OkrRepository, moonshots: &MoonshotRubric) -> Result<()> {
474 if moonshots.goals.is_empty() {
475 return seed_default_okr_if_empty(repo).await;
476 }
477 let _ = seed_moonshot_okr_if_empty(repo, moonshots).await?;
478 Ok(())
479}
480
481async fn seed_moonshot_okr_if_empty(
482 repo: &OkrRepository,
483 moonshots: &MoonshotRubric,
484) -> Result<Option<Uuid>> {
485 let existing = repo.list_okrs().await?;
486 if !existing.is_empty() || moonshots.goals.is_empty() {
487 return Ok(None);
488 }
489 seed_moonshot_okr(repo, moonshots).await.map(Some)
490}
491
492async fn seed_moonshot_okr_if_no_opportunities(
493 repo: &OkrRepository,
494 moonshots: &MoonshotRubric,
495) -> Result<Option<Uuid>> {
496 if moonshots.goals.is_empty() {
497 return Ok(None);
498 }
499
500 let existing = repo.list_okrs().await?;
503 let has_open_moonshot_seed = existing.iter().any(|okr| {
504 matches!(
505 okr.status,
506 OkrStatus::Active | OkrStatus::Draft | OkrStatus::OnHold
507 ) && okr.title == "Mission: Moonshot-Derived Autonomous Execution"
508 && okr
509 .key_results
510 .iter()
511 .any(|kr| kr.progress().clamp(0.0, 1.0) < 1.0)
512 });
513 if has_open_moonshot_seed {
514 return Ok(None);
515 }
516
517 seed_moonshot_okr(repo, moonshots).await.map(Some)
518}
519
520async fn seed_moonshot_okr(repo: &OkrRepository, moonshots: &MoonshotRubric) -> Result<Uuid> {
521 let mut okr = Okr::new(
522 "Mission: Moonshot-Derived Autonomous Execution",
523 format!(
524 "Autogenerated forage objective derived from moonshots.\nMoonshots:\n- {}",
525 moonshots.goals.join("\n- ")
526 ),
527 );
528 okr.status = OkrStatus::Active;
529 let okr_id = okr.id;
530
531 let max_goals = 8usize;
532 for (idx, goal) in moonshots.goals.iter().take(max_goals).enumerate() {
533 let mut kr = KeyResult::new(
534 okr_id,
535 format!("Moonshot {}: {}", idx + 1, truncate_goal(goal, 96)),
536 100.0,
537 "%",
538 );
539 kr.description = goal.clone();
540 okr.add_key_result(kr);
541 }
542
543 if okr.key_results.is_empty() {
544 okr.add_key_result(KeyResult::new(
545 okr_id,
546 "Moonshot alignment delivered",
547 100.0,
548 "%",
549 ));
550 }
551
552 let _ = repo.create_okr(okr).await?;
553 tracing::info!(
554 okr_id = %okr_id,
555 moonshot_count = moonshots.goals.len(),
556 "Seeded moonshot-derived OKR for forage execution"
557 );
558 Ok(okr_id)
559}
560
561fn truncate_goal(goal: &str, max_chars: usize) -> String {
562 let trimmed = goal.trim();
563 if trimmed.chars().count() <= max_chars {
564 return trimmed.to_string();
565 }
566 truncate_chars_with_suffix(trimmed, max_chars, "...")
567}
568
569fn truncate_chars_with_suffix(value: &str, max_chars: usize, suffix: &str) -> String {
570 if value.chars().count() <= max_chars {
571 return value.to_string();
572 }
573
574 let mut out = value.chars().take(max_chars).collect::<String>();
575 out.push_str(suffix);
576 out
577}
578
579async fn start_required_bus_s3_sink(
580 bus: std::sync::Arc<AgentBus>,
581) -> Result<tokio::task::JoinHandle<Result<()>>> {
582 let config = BusS3SinkConfig::from_env_or_vault().await.context(
583 "Forage requires S3 bus archival. Configure MINIO_*/CODETETHER_CHAT_SYNC_MINIO_* or Vault provider 'chat-sync-minio'.",
584 )?;
585 let sink = BusS3Sink::from_config(bus, config)
586 .await
587 .context("Failed to initialize required S3 bus sink for forage")?;
588 Ok(tokio::spawn(async move { sink.run().await }))
589}
590
591async fn ensure_s3_sync_alive(
592 handle: &mut Option<tokio::task::JoinHandle<Result<()>>>,
593) -> Result<()> {
594 let Some(inner) = handle.as_ref() else {
595 anyhow::bail!("S3 sync task missing");
596 };
597 if !inner.is_finished() {
598 return Ok(());
599 }
600
601 let finished = handle.take().expect("checked is_some");
602 match finished.await {
603 Ok(Ok(())) => {
604 anyhow::bail!("S3 sync task exited unexpectedly");
605 }
606 Ok(Err(err)) => Err(anyhow::anyhow!("S3 sync task failed: {err:#}")),
607 Err(join_err) => Err(anyhow::anyhow!("S3 sync task join failure: {join_err}")),
608 }
609}
610
611async fn record_execution_success_to_okr(
612 repo: &OkrRepository,
613 item: &ForageOpportunity,
614 args: &ForageArgs,
615 execution_outcome: &ExecutionOutcome,
616 cycle: usize,
617) -> Result<()> {
618 let Some(mut okr) = repo.get_okr(item.okr_id).await? else {
619 anyhow::bail!("OKR {} not found", item.okr_id);
620 };
621
622 let Some(kr) = okr
623 .key_results
624 .iter_mut()
625 .find(|kr| kr.id == item.key_result_id)
626 else {
627 anyhow::bail!("KR {} not found in OKR {}", item.key_result_id, item.okr_id);
628 };
629
630 let enforce_concrete_file_evidence =
631 args.execution_engine == "swarm" || args.execution_engine == "go";
632 let has_file_evidence = !execution_outcome.changed_files.is_empty();
633 let quality_gates_passed = execution_outcome.quality_gates_passed;
634
635 let should_increment =
639 (has_file_evidence || !enforce_concrete_file_evidence) && quality_gates_passed;
640
641 let before_progress = kr.progress();
642 if should_increment {
643 let increment_ratio = success_progress_increment_ratio(kr);
644 if kr.target_value > 0.0 && increment_ratio > 0.0 {
645 let increment_value = (kr.target_value * increment_ratio).max(0.0);
646 let new_value = (kr.current_value + increment_value).min(kr.target_value);
647 kr.update_progress(new_value);
648 }
649 }
650 let after_progress = kr.progress();
651
652 let progress_impact = if quality_gates_passed {
653 if should_increment {
654 "advanced this KR"
655 } else {
656 "no concrete changed-file evidence was found; KR progress not incremented"
657 }
658 } else {
659 "quality gates FAILED; KR progress not incremented"
660 };
661
662 let mut kr_outcome = KrOutcome::new(
663 kr.id,
664 format!(
665 "Forage cycle {cycle} execution via '{}' {}.",
666 args.execution_engine, progress_impact
667 ),
668 );
669 kr_outcome.outcome_type = KrOutcomeType::CodeChange;
670 kr_outcome.value = Some((after_progress * 100.0).clamp(0.0, 100.0));
671 kr_outcome.source = "forage-runtime".to_string();
672 kr_outcome.evidence = vec![
673 format!("cycle:{cycle}"),
674 format!("engine:{}", args.execution_engine),
675 format!("model:{}", args.model.as_deref().unwrap_or("default")),
676 format!("score:{:.3}", item.score),
677 format!("okr_id:{}", item.okr_id),
678 format!("kr_id:{}", item.key_result_id),
679 format!("progress_before_pct:{:.2}", before_progress * 100.0),
680 format!("progress_after_pct:{:.2}", after_progress * 100.0),
681 format!(
682 "detail:{}",
683 normalize_prompt_field(&execution_outcome.detail, 320)
684 ),
685 format!("concrete_file_evidence:{}", has_file_evidence),
686 format!("quality_gates_passed:{}", quality_gates_passed),
687 ];
688 let max_files = 40usize;
689 for path in execution_outcome.changed_files.iter().take(max_files) {
690 kr_outcome.evidence.push(format!("file:{path}"));
691 }
692 if execution_outcome.changed_files.len() > max_files {
693 kr_outcome.evidence.push(format!(
694 "files_truncated:{}",
695 execution_outcome.changed_files.len() - max_files
696 ));
697 }
698 kr.add_outcome(kr_outcome);
699
700 if matches!(okr.status, OkrStatus::Draft | OkrStatus::OnHold) && after_progress > 0.0 {
701 okr.status = OkrStatus::Active;
702 }
703 if okr.is_complete() {
704 okr.status = OkrStatus::Completed;
705 }
706
707 let _ = repo.update_okr(okr).await?;
708 Ok(())
709}
710
711fn success_progress_increment_ratio(kr: &KeyResult) -> f64 {
712 if kr.target_value <= 0.0 {
713 return 0.0;
714 }
715 let remaining_ratio = ((kr.target_value - kr.current_value) / kr.target_value).clamp(0.0, 1.0);
716 (remaining_ratio * 0.25).clamp(0.05, 0.15)
717}
718
719fn snapshot_git_changed_files() -> Option<BTreeSet<String>> {
720 let cwd = std::env::current_dir().ok()?;
721 let is_git_repo = Command::new("git")
722 .args(["rev-parse", "--is-inside-work-tree"])
723 .current_dir(&cwd)
724 .output()
725 .ok()?
726 .status
727 .success();
728 if !is_git_repo {
729 return None;
730 }
731
732 let mut changed = BTreeSet::new();
733 changed.extend(git_name_list(&cwd, &["diff", "--name-only"]));
734 changed.extend(git_name_list(&cwd, &["diff", "--name-only", "--cached"]));
735 changed.extend(git_name_list(
736 &cwd,
737 &["ls-files", "--others", "--exclude-standard"],
738 ));
739 Some(changed)
740}
741
742fn git_name_list(cwd: &std::path::Path, args: &[&str]) -> Vec<String> {
743 let Ok(output) = Command::new("git").args(args).current_dir(cwd).output() else {
744 return Vec::new();
745 };
746 if !output.status.success() {
747 return Vec::new();
748 }
749
750 String::from_utf8_lossy(&output.stdout)
751 .lines()
752 .map(str::trim)
753 .filter(|line| !line.is_empty())
754 .map(ToString::to_string)
755 .collect()
756}
757
758async fn execute_opportunity(
759 item: &ForageOpportunity,
760 args: &ForageArgs,
761) -> Result<ExecutionOutcome> {
762 let before = snapshot_git_changed_files();
763 let detail = match args.execution_engine.as_str() {
764 "swarm" => execute_opportunity_with_swarm(item, args).await?,
765 "go" => execute_opportunity_with_go(item, args).await?,
766 _ => execute_opportunity_with_run(item, args).await?,
767 };
768 let after = snapshot_git_changed_files();
769 let changed_files = match (before, after) {
770 (Some(before_set), Some(after_set)) => after_set.difference(&before_set).cloned().collect(),
771 (_, Some(after_set)) => after_set.into_iter().collect(),
772 _ => Vec::new(),
773 };
774
775 let quality_result = run_quality_gates(&changed_files).await;
777
778 let (final_detail, quality_passed) = match quality_result {
779 Ok((qr, passed)) => (format!("{}\n\nQuality gates: {}", detail, qr), passed),
780 Err(e) => {
781 tracing::warn!(error = %e, "Quality gates failed to run");
782 (detail, false) }
784 };
785
786 Ok(ExecutionOutcome {
787 detail: final_detail,
788 changed_files,
789 quality_gates_passed: quality_passed,
790 })
791}
792
793async fn run_quality_gates(changed_files: &[String]) -> Result<(String, bool)> {
795 let has_rust_files = changed_files.iter().any(|f| f.ends_with(".rs"));
797
798 if !has_rust_files {
799 return Ok((
800 "no Rust files changed, skipping quality gates".to_string(),
801 true,
802 ));
803 }
804
805 let mut results = Vec::new();
806 let mut all_passed = true;
807
808 let check_output = Command::new("cargo")
810 .args(["check", "--message-format=short"])
811 .output();
812
813 match check_output {
814 Ok(output) => {
815 let status = if output.status.success() {
816 "PASS"
817 } else {
818 all_passed = false;
819 "FAIL"
820 };
821 let stderr = String::from_utf8_lossy(&output.stderr);
822 let summary = if stderr.chars().count() > 200 {
823 truncate_chars_with_suffix(stderr.as_ref(), 200, " [...truncated]")
824 } else if stderr.is_empty() {
825 "no errors".to_string()
826 } else {
827 stderr.to_string()
828 };
829 results.push(format!("cargo check: {} - {}", status, summary));
830 }
831 Err(e) => {
832 results.push(format!("cargo check: ERROR - {}", e));
833 all_passed = false;
834 }
835 }
836
837 let test_output = Command::new("cargo")
839 .args(["test", "--quiet", "--", "--nocapture"])
840 .output();
841
842 match test_output {
843 Ok(output) => {
844 let status = if output.status.success() {
845 "PASS"
846 } else {
847 all_passed = false;
848 "FAIL"
849 };
850 let stdout = String::from_utf8_lossy(&output.stdout);
851 let summary = if stdout.chars().count() > 300 {
852 truncate_chars_with_suffix(stdout.as_ref(), 300, " [...truncated]")
853 } else if stdout.is_empty() {
854 "no test output".to_string()
855 } else {
856 stdout.to_string()
857 };
858 results.push(format!("cargo test: {} - {}", status, summary));
859 }
860 Err(e) => {
861 results.push(format!("cargo test: ERROR - {}", e));
862 all_passed = false;
863 }
864 }
865
866 Ok((results.join("\n"), all_passed))
867}
868
869async fn execute_opportunity_with_run(
870 item: &ForageOpportunity,
871 args: &ForageArgs,
872) -> Result<String> {
873 let run_args = RunArgs {
874 message: item.prompt.clone(),
875 continue_session: false,
876 session: None,
877 model: args.model.clone(),
878 agent: Some("build".to_string()),
879 format: "default".to_string(),
880 file: Vec::new(),
881 codex_session: None,
882 max_steps: None,
883 };
884 let timeout_secs = args.run_timeout_secs.clamp(30, 86_400);
885 match tokio::time::timeout(
886 Duration::from_secs(timeout_secs),
887 crate::cli::run::execute(run_args),
888 )
889 .await
890 {
891 Ok(Ok(())) => Ok("run execution completed".to_string()),
892 Ok(Err(err)) => Err(err),
893 Err(_) => anyhow::bail!("run execution timed out after {timeout_secs}s"),
894 }
895}
896
897async fn execute_opportunity_with_swarm(
898 item: &ForageOpportunity,
899 args: &ForageArgs,
900) -> Result<String> {
901 let timeout_secs = args.run_timeout_secs.clamp(30, 86_400);
902 let swarm_config = build_swarm_config(args);
903 let executor = SwarmExecutor::new(swarm_config);
904 let strategy = parse_swarm_strategy(&args.swarm_strategy);
905
906 match tokio::time::timeout(
907 Duration::from_secs(timeout_secs),
908 executor.execute(&item.prompt, strategy),
909 )
910 .await
911 {
912 Ok(Ok(result)) => {
913 if result.success {
914 Ok(format!(
915 "swarm execution completed (subagents_spawned={}, completed={}, failed={}, retries={})",
916 result.stats.subagents_spawned,
917 result.stats.subagents_completed,
918 result.stats.subagents_failed,
919 result
920 .subtask_results
921 .iter()
922 .map(|r| r.retry_count)
923 .sum::<u32>() as usize
924 ))
925 } else {
926 let error = result.error.unwrap_or_else(|| {
927 format!(
928 "swarm reported failure (failed_subtasks={}, total_subtasks={})",
929 result.stats.subagents_failed, result.stats.subagents_spawned
930 )
931 });
932 anyhow::bail!(error);
933 }
934 }
935 Ok(Err(err)) => Err(err),
936 Err(_) => anyhow::bail!("swarm execution timed out after {timeout_secs}s"),
937 }
938}
939
940fn build_swarm_config(args: &ForageArgs) -> SwarmConfig {
941 SwarmConfig {
942 max_subagents: args.swarm_max_subagents.max(1),
943 max_steps_per_subagent: args.swarm_max_steps.max(1),
944 subagent_timeout_secs: args.swarm_subagent_timeout_secs.clamp(30, 86_400),
945 model: args.model.clone(),
946 execution_mode: ExecutionMode::LocalThread,
947 ..Default::default()
948 }
949}
950
951async fn execute_opportunity_with_go(
953 item: &ForageOpportunity,
954 args: &ForageArgs,
955) -> Result<String> {
956 use crate::cli::go_ralph::execute_go_ralph;
957
958 let timeout_secs = args.run_timeout_secs.clamp(30, 86_400);
959 let model = args
960 .model
961 .clone()
962 .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string());
963
964 let registry = ProviderRegistry::from_vault()
966 .await
967 .context("Failed to load provider registry for go engine")?;
968
969 let (provider, resolved_model) = registry
970 .resolve_model(&model)
971 .with_context(|| format!("Failed to resolve model '{}' for go engine", model))?;
972
973 let repo = OkrRepository::from_config()
975 .await
976 .context("Failed to load OKR repository")?;
977
978 let mut okr = repo
979 .get_okr(item.okr_id)
980 .await?
981 .with_context(|| format!("OKR {} not found", item.okr_id))?;
982
983 let mut okr_run = OkrRun::new(item.okr_id, format!("forage-cycle-{}", item.key_result_id));
985 okr_run.submit_for_approval()?;
986 okr_run.record_decision(crate::okr::ApprovalDecision::approve(
987 okr_run.id,
988 "Auto-approved from forage execution",
989 ));
990
991 let task = item.prompt.clone();
993
994 match tokio::time::timeout(
996 Duration::from_secs(timeout_secs),
997 execute_go_ralph(
998 &task,
999 &mut okr,
1000 &mut okr_run,
1001 provider,
1002 &resolved_model,
1003 10, None, 3, None, ),
1008 )
1009 .await
1010 {
1011 Ok(Ok(result)) => {
1012 if result.all_passed {
1013 Ok(format!(
1014 "go execution completed - all {}/{} stories passed (iterations: {}/{}, branch: {})",
1015 result.passed,
1016 result.total,
1017 result.iterations,
1018 result.max_iterations,
1019 result.feature_branch
1020 ))
1021 } else {
1022 Ok(format!(
1023 "go execution completed - {}/{} stories passed (iterations: {}/{}, branch: {}, status: {:?})",
1024 result.passed,
1025 result.total,
1026 result.iterations,
1027 result.max_iterations,
1028 result.feature_branch,
1029 result.status
1030 ))
1031 }
1032 }
1033 Ok(Err(err)) => {
1034 okr_run.status = OkrRunStatus::Failed;
1036 Err(err).context("go execution failed")
1037 }
1038 Err(_) => anyhow::bail!("go execution timed out after {timeout_secs}s"),
1039 }
1040}
1041
1042fn parse_swarm_strategy(value: &str) -> DecompositionStrategy {
1043 match value {
1044 "domain" => DecompositionStrategy::ByDomain,
1045 "data" => DecompositionStrategy::ByData,
1046 "stage" => DecompositionStrategy::ByStage,
1047 "none" => DecompositionStrategy::None,
1048 _ => DecompositionStrategy::Automatic,
1049 }
1050}
1051
1052async fn ensure_audit_log_initialized() {
1053 if audit::try_audit_log().is_some() {
1054 return;
1055 }
1056
1057 let default_sink = crate::config::Config::data_dir()
1058 .map(|base| base.join("audit"))
1059 .map(|audit_dir| {
1060 let _ = std::fs::create_dir_all(&audit_dir);
1061 audit_dir.join("forage_audit.jsonl")
1062 });
1063 let log = if std::env::var("CODETETHER_AUDIT_LOG_PATH").is_ok() {
1064 AuditLog::from_env()
1065 } else {
1066 AuditLog::new(10_000, default_sink)
1067 };
1068 let _ = audit::init_audit_log(log);
1069}
1070
1071async fn log_audit(
1072 category: AuditCategory,
1073 action: &str,
1074 outcome: AuditOutcome,
1075 detail: Option<serde_json::Value>,
1076 okr_id: Option<Uuid>,
1077 session_id: Option<String>,
1078) {
1079 if let Some(audit_log) = audit::try_audit_log() {
1080 audit_log
1081 .log_with_correlation(
1082 category,
1083 action,
1084 outcome,
1085 Some("forage-runtime".to_string()),
1086 detail,
1087 okr_id.map(|id| id.to_string()),
1088 None,
1089 None,
1090 session_id,
1091 )
1092 .await;
1093 }
1094}
1095
1096fn flush_bus_observer(observer: &mut BusHandle, cycle: usize, json_mode: bool) {
1097 if json_mode {
1098 return;
1099 }
1100
1101 let mut shown = 0usize;
1102 while let Some(env) = observer.try_recv() {
1103 if shown >= 12 {
1104 break;
1105 }
1106 let label = match &env.message {
1107 BusMessage::AgentReady { .. } => "agent_ready",
1108 BusMessage::AgentShutdown { .. } => "agent_shutdown",
1109 BusMessage::TaskUpdate { .. } => "task_update",
1110 BusMessage::SharedResult { .. } => "shared_result",
1111 BusMessage::AgentMessage { .. } => "agent_message",
1112 _ => "other",
1113 };
1114 println!(
1115 " [bus cycle {cycle}] {} :: topic={} sender={}",
1116 label, env.topic, env.sender_id
1117 );
1118 shown = shown.saturating_add(1);
1119 }
1120}
1121
1122async fn load_moonshot_rubric(args: &ForageArgs) -> Result<MoonshotRubric> {
1123 let mut goals = args
1124 .moonshots
1125 .iter()
1126 .map(|s| s.trim())
1127 .filter(|s| !s.is_empty())
1128 .map(ToString::to_string)
1129 .collect::<Vec<_>>();
1130
1131 if let Some(path) = &args.moonshot_file {
1132 let content = tokio::fs::read_to_string(path)
1133 .await
1134 .with_context(|| format!("Failed to read moonshot file: {}", path.display()))?;
1135
1136 if let Ok(json_list) = serde_json::from_str::<Vec<String>>(&content) {
1137 goals.extend(
1138 json_list
1139 .into_iter()
1140 .map(|s| s.trim().to_string())
1141 .filter(|s| !s.is_empty()),
1142 );
1143 } else {
1144 goals.extend(
1145 content
1146 .lines()
1147 .map(str::trim)
1148 .filter(|line| !line.is_empty() && !line.starts_with('#'))
1149 .map(ToString::to_string),
1150 );
1151 }
1152 }
1153
1154 let mut deduped = Vec::new();
1155 let mut seen = HashSet::new();
1156 for goal in goals {
1157 let key = goal.to_ascii_lowercase();
1158 if seen.insert(key) {
1159 deduped.push(goal);
1160 }
1161 }
1162
1163 Ok(MoonshotRubric {
1164 goals: deduped,
1165 required: args.moonshot_required,
1166 min_alignment: args.moonshot_min_alignment.clamp(0.0, 1.0),
1167 })
1168}
1169
1170async fn build_opportunities(
1171 repo: &OkrRepository,
1172 moonshots: &MoonshotRubric,
1173) -> Result<Vec<ForageOpportunity>> {
1174 let okrs = repo.list_okrs().await?;
1175 Ok(collect_opportunities_with_rubric(&okrs, moonshots))
1176}
1177
1178#[cfg(test)]
1179fn collect_opportunities(okrs: &[Okr]) -> Vec<ForageOpportunity> {
1180 collect_opportunities_with_rubric(okrs, &MoonshotRubric::default())
1181}
1182
1183fn collect_opportunities_with_rubric(
1184 okrs: &[Okr],
1185 moonshots: &MoonshotRubric,
1186) -> Vec<ForageOpportunity> {
1187 let now = Utc::now();
1188 let mut items = Vec::new();
1189
1190 for okr in okrs {
1191 let status_weight = status_weight(okr.status);
1192 if status_weight <= 0.0 {
1193 continue;
1194 }
1195
1196 for kr in &okr.key_results {
1197 let progress = kr.progress().clamp(0.0, 1.0);
1198 if progress >= 1.0 {
1199 continue;
1200 }
1201 let remaining = (1.0 - progress).clamp(0.0, 1.0);
1202 let urgency_bonus = urgency_bonus(okr.target_date, now);
1203 let alignment_context = format!(
1204 "{} {} {} {}",
1205 okr.title, okr.description, kr.title, kr.description
1206 );
1207 let moonshot_alignment = moonshot_alignment_score(&alignment_context, &moonshots.goals);
1208 if moonshots.required && moonshot_alignment < moonshots.min_alignment {
1209 continue;
1210 }
1211 let moonshot_hits = matching_moonshots(&alignment_context, &moonshots.goals);
1212 let moonshot_bonus = if moonshots.goals.is_empty() {
1213 0.0
1214 } else {
1215 (moonshot_alignment * 0.5).min(0.5)
1216 };
1217 let score = (remaining * status_weight) + urgency_bonus + moonshot_bonus;
1218 let prompt = build_execution_prompt(okr, kr, moonshots, moonshot_alignment);
1219
1220 items.push(ForageOpportunity {
1221 score,
1222 okr_id: okr.id,
1223 okr_title: okr.title.clone(),
1224 okr_status: okr.status,
1225 key_result_id: kr.id,
1226 key_result_title: kr.title.clone(),
1227 progress,
1228 remaining,
1229 target_date: okr.target_date,
1230 moonshot_alignment,
1231 moonshot_hits,
1232 prompt,
1233 });
1234 }
1235 }
1236
1237 items.sort_by(
1238 |a, b| match b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal) {
1239 Ordering::Equal => b
1240 .remaining
1241 .partial_cmp(&a.remaining)
1242 .unwrap_or(Ordering::Equal),
1243 other => other,
1244 },
1245 );
1246 items
1247}
1248
1249fn tokenize_for_alignment(value: &str) -> HashSet<String> {
1250 value
1251 .split(|c: char| !c.is_ascii_alphanumeric())
1252 .map(|s| s.trim().to_ascii_lowercase())
1253 .filter(|s| s.len() >= 4)
1254 .collect()
1255}
1256
1257fn moonshot_alignment_score(context: &str, goals: &[String]) -> f64 {
1258 if goals.is_empty() {
1259 return 0.0;
1260 }
1261
1262 let context_tokens = tokenize_for_alignment(context);
1263 if context_tokens.is_empty() {
1264 return 0.0;
1265 }
1266
1267 goals
1268 .iter()
1269 .map(|goal| {
1270 let goal_tokens = tokenize_for_alignment(goal);
1271 if goal_tokens.is_empty() {
1272 return 0.0;
1273 }
1274 let overlap = goal_tokens.intersection(&context_tokens).count() as f64;
1275 overlap / goal_tokens.len() as f64
1276 })
1277 .fold(0.0, f64::max)
1278}
1279
1280fn matching_moonshots(context: &str, goals: &[String]) -> Vec<String> {
1281 if goals.is_empty() {
1282 return Vec::new();
1283 }
1284 let context_tokens = tokenize_for_alignment(context);
1285 let mut hits = goals
1286 .iter()
1287 .filter_map(|goal| {
1288 let goal_tokens = tokenize_for_alignment(goal);
1289 if goal_tokens.is_empty() {
1290 return None;
1291 }
1292 let overlap = goal_tokens.intersection(&context_tokens).count();
1293 if overlap == 0 {
1294 None
1295 } else {
1296 Some((overlap, goal.clone()))
1297 }
1298 })
1299 .collect::<Vec<_>>();
1300 hits.sort_by(|a, b| b.0.cmp(&a.0));
1301 hits.into_iter().take(3).map(|(_, goal)| goal).collect()
1302}
1303
1304fn status_weight(status: OkrStatus) -> f64 {
1305 match status {
1306 OkrStatus::Active => 1.0,
1307 OkrStatus::Draft => 0.65,
1308 OkrStatus::OnHold => 0.35,
1309 OkrStatus::Completed | OkrStatus::Cancelled => 0.0,
1310 }
1311}
1312
1313fn urgency_bonus(target_date: Option<DateTime<Utc>>, now: DateTime<Utc>) -> f64 {
1314 let Some(target_date) = target_date else {
1315 return 0.0;
1316 };
1317
1318 if target_date <= now {
1319 return 0.35;
1320 }
1321
1322 let days = (target_date - now).num_days();
1323 if days <= 7 {
1324 0.25
1325 } else if days <= 30 {
1326 0.1
1327 } else {
1328 0.0
1329 }
1330}
1331
1332const MAX_PROMPT_FIELD_CHARS: usize = 1_200;
1333
1334fn normalize_prompt_field(value: &str, max_chars: usize) -> String {
1335 let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
1336 let normalized_max = max_chars.max(32);
1337 if compact.chars().count() <= normalized_max {
1338 return compact;
1339 }
1340
1341 let mut truncated = compact.chars().take(normalized_max).collect::<String>();
1342 truncated.push_str(" ...(truncated)");
1343 truncated
1344}
1345
1346fn build_execution_prompt(
1347 okr: &Okr,
1348 kr: &KeyResult,
1349 moonshots: &MoonshotRubric,
1350 moonshot_alignment: f64,
1351) -> String {
1352 let objective = normalize_prompt_field(&okr.title, MAX_PROMPT_FIELD_CHARS);
1353 let objective_description = normalize_prompt_field(&okr.description, MAX_PROMPT_FIELD_CHARS);
1354 let key_result = normalize_prompt_field(&kr.title, MAX_PROMPT_FIELD_CHARS);
1355 let key_result_description = normalize_prompt_field(&kr.description, MAX_PROMPT_FIELD_CHARS);
1356 let moonshot_section = if moonshots.goals.is_empty() {
1357 String::new()
1358 } else {
1359 format!(
1360 "\nMoonshot Rubric (strategy filter):\n- {}\nCurrent alignment score: {:.1}%\nDecision rule: prioritize changes that clearly advance one or more moonshots and explain which mission the change moves.",
1361 moonshots
1362 .goals
1363 .iter()
1364 .map(|g| normalize_prompt_field(g, 160))
1365 .collect::<Vec<_>>()
1366 .join("\n- "),
1367 moonshot_alignment * 100.0
1368 )
1369 };
1370
1371 format!(
1372 "Business-goal execution task.\n\
1373Objective: {}\n\
1374Objective Description: {}\n\
1375Key Result: {}\n\
1376KR Description: {}\n\
1377Current: {:.3} {} | Target: {:.3} {}\n\n\
1378Execute one concrete, behavior-preserving code change that measurably advances this key result. \
1379Use tools, validate the change, and report exact evidence tied to the KR.\n\
1380Focus on local repository changes first; do not do broad web research unless required by the KR.\n\
1381Return exact changed file paths and at least one verification command result.{}",
1382 objective,
1383 objective_description,
1384 key_result,
1385 key_result_description,
1386 kr.current_value,
1387 kr.unit,
1388 kr.target_value,
1389 kr.unit,
1390 moonshot_section
1391 )
1392}
1393
1394impl ForageOpportunity {
1395 fn okr_status_label(&self) -> &'static str {
1396 match self.okr_status {
1397 OkrStatus::Draft => "draft",
1398 OkrStatus::Active => "active",
1399 OkrStatus::Completed => "completed",
1400 OkrStatus::Cancelled => "cancelled",
1401 OkrStatus::OnHold => "on_hold",
1402 }
1403 }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use super::{
1409 ExecutionOutcome, ForageOpportunity, MoonshotRubric, build_swarm_config,
1410 collect_opportunities, collect_opportunities_with_rubric, normalize_prompt_field,
1411 record_execution_success_to_okr, seed_default_okr_if_empty,
1412 seed_moonshot_okr_if_no_opportunities, status_weight, success_progress_increment_ratio,
1413 urgency_bonus,
1414 };
1415 use crate::cli::ForageArgs;
1416 use crate::okr::{KeyResult, Okr, OkrStatus};
1417 use chrono::{Duration, Utc};
1418 use tempfile::tempdir;
1419 use uuid::Uuid;
1420
1421 #[test]
1422 fn status_weight_prioritizes_active_okrs() {
1423 assert!(status_weight(OkrStatus::Active) > status_weight(OkrStatus::Draft));
1424 assert!(status_weight(OkrStatus::Draft) > status_weight(OkrStatus::OnHold));
1425 assert_eq!(status_weight(OkrStatus::Completed), 0.0);
1426 }
1427
1428 #[test]
1429 fn urgency_bonus_increases_for_due_dates() {
1430 let now = Utc::now();
1431 let overdue = urgency_bonus(Some(now - Duration::days(1)), now);
1432 let soon = urgency_bonus(Some(now + Duration::days(3)), now);
1433 let later = urgency_bonus(Some(now + Duration::days(45)), now);
1434 assert!(overdue > soon);
1435 assert!(soon > later);
1436 }
1437
1438 #[test]
1439 fn collect_opportunities_skips_complete_or_cancelled_work() {
1440 let mut okr = Okr::new("Ship growth loop", "Increase retained users");
1441 okr.status = OkrStatus::Cancelled;
1442 let mut kr = KeyResult::new(okr.id, "Retained users", 100.0, "%");
1443 kr.update_progress(10.0);
1444 okr.add_key_result(kr);
1445
1446 let items = collect_opportunities(&[okr]);
1447 assert!(items.is_empty());
1448 }
1449
1450 #[test]
1451 fn collect_opportunities_ranks_remaining_work() {
1452 let mut okr = Okr::new("Ship growth loop", "Increase retained users");
1453 okr.status = OkrStatus::Active;
1454
1455 let mut kr_low = KeyResult::new(okr.id, "KR Low Remaining", 100.0, "%");
1456 kr_low.update_progress(80.0);
1457 let mut kr_high = KeyResult::new(okr.id, "KR High Remaining", 100.0, "%");
1458 kr_high.update_progress(10.0);
1459 okr.add_key_result(kr_low);
1460 okr.add_key_result(kr_high);
1461
1462 let items = collect_opportunities(&[okr]);
1463 assert_eq!(items.len(), 2);
1464 assert_eq!(items[0].key_result_title, "KR High Remaining");
1465 }
1466
1467 #[test]
1468 fn moonshot_rubric_filters_low_alignment_work() {
1469 let mut okr = Okr::new(
1470 "Improve parser latency",
1471 "Reduce p95 latency for parser pipeline",
1472 );
1473 okr.status = OkrStatus::Active;
1474 let mut kr = KeyResult::new(okr.id, "Parser p95 under 50ms", 100.0, "%");
1475 kr.update_progress(10.0);
1476 okr.add_key_result(kr);
1477
1478 let rubric = MoonshotRubric {
1479 goals: vec!["eliminate billing fraud globally".to_string()],
1480 required: true,
1481 min_alignment: 0.4,
1482 };
1483
1484 let items = collect_opportunities_with_rubric(&[okr], &rubric);
1485 assert!(
1486 items.is_empty(),
1487 "non-aligned work should be filtered out when moonshot is required"
1488 );
1489 }
1490
1491 #[test]
1492 fn forage_timeout_bounds_are_clamped() {
1493 let low = 5u64.clamp(30, 86_400);
1494 let high = 999_999u64.clamp(30, 86_400);
1495 assert_eq!(low, 30);
1496 assert_eq!(high, 86_400);
1497 }
1498
1499 #[test]
1500 fn swarm_config_does_not_force_legacy_model_fallback() {
1501 let args = ForageArgs {
1502 top: 3,
1503 loop_mode: false,
1504 interval_secs: 120,
1505 max_cycles: 1,
1506 execute: true,
1507 moonshots: Vec::new(),
1508 moonshot_file: None,
1509 moonshot_required: false,
1510 moonshot_min_alignment: 0.10,
1511 execution_engine: "swarm".to_string(),
1512 run_timeout_secs: 900,
1513 fail_fast: false,
1514 swarm_strategy: "auto".to_string(),
1515 swarm_max_subagents: 8,
1516 swarm_max_steps: 100,
1517 swarm_subagent_timeout_secs: 300,
1518 model: None,
1519 json: false,
1520 no_s3: false,
1521 };
1522
1523 let config = build_swarm_config(&args);
1524 assert!(config.model.is_none());
1525 }
1526
1527 #[test]
1528 fn swarm_config_preserves_explicit_model_override() {
1529 let args = ForageArgs {
1530 top: 3,
1531 loop_mode: false,
1532 interval_secs: 120,
1533 max_cycles: 1,
1534 execute: true,
1535 moonshots: Vec::new(),
1536 moonshot_file: None,
1537 moonshot_required: false,
1538 moonshot_min_alignment: 0.10,
1539 execution_engine: "swarm".to_string(),
1540 run_timeout_secs: 900,
1541 fail_fast: false,
1542 swarm_strategy: "auto".to_string(),
1543 swarm_max_subagents: 8,
1544 swarm_max_steps: 100,
1545 swarm_subagent_timeout_secs: 300,
1546 model: Some("openai-codex/gpt-5-mini".to_string()),
1547 json: false,
1548 no_s3: false,
1549 };
1550
1551 let config = build_swarm_config(&args);
1552 assert_eq!(config.model.as_deref(), Some("openai-codex/gpt-5-mini"));
1553 }
1554
1555 #[test]
1556 fn normalize_prompt_field_compacts_and_truncates() {
1557 let input = "alpha beta\n\n gamma delta";
1558 assert_eq!(normalize_prompt_field(input, 128), "alpha beta gamma delta");
1559
1560 let long = "x".repeat(400);
1561 let normalized = normalize_prompt_field(&long, 64);
1562 assert!(normalized.ends_with("...(truncated)"));
1563 assert!(normalized.len() > 64);
1564 }
1565
1566 #[test]
1567 fn success_progress_increment_ratio_is_bounded() {
1568 let mut kr = KeyResult::new(Uuid::new_v4(), "KR", 100.0, "%");
1569 kr.update_progress(0.0);
1570 let high_remaining = success_progress_increment_ratio(&kr);
1571 assert!((high_remaining - 0.15).abs() < f64::EPSILON);
1572
1573 kr.update_progress(95.0);
1574 let low_remaining = success_progress_increment_ratio(&kr);
1575 assert!((low_remaining - 0.05).abs() < f64::EPSILON);
1576 }
1577
1578 #[tokio::test]
1579 async fn record_execution_success_updates_kr_progress_and_evidence() {
1580 let dir = tempdir().expect("create tempdir");
1581 let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1582
1583 let mut okr = Okr::new("Autonomous Business-Aligned Execution", "Test objective");
1584 okr.status = OkrStatus::Active;
1585 let mut kr = KeyResult::new(okr.id, "KR1", 100.0, "%");
1586 kr.update_progress(0.0);
1587 let kr_id = kr.id;
1588 okr.add_key_result(kr);
1589 let okr_id = okr.id;
1590 let _ = repo.create_okr(okr).await.expect("create okr");
1591
1592 let item = ForageOpportunity {
1593 score: 1.35,
1594 okr_id,
1595 okr_title: "Autonomous Business-Aligned Execution".to_string(),
1596 okr_status: OkrStatus::Active,
1597 key_result_id: kr_id,
1598 key_result_title: "KR1".to_string(),
1599 progress: 0.0,
1600 remaining: 1.0,
1601 target_date: None,
1602 moonshot_alignment: 0.0,
1603 moonshot_hits: Vec::new(),
1604 prompt: "test prompt".to_string(),
1605 };
1606 let args = ForageArgs {
1607 top: 3,
1608 loop_mode: false,
1609 interval_secs: 120,
1610 max_cycles: 1,
1611 execute: true,
1612 moonshots: Vec::new(),
1613 moonshot_file: None,
1614 moonshot_required: false,
1615 moonshot_min_alignment: 0.10,
1616 execution_engine: "run".to_string(),
1617 run_timeout_secs: 900,
1618 fail_fast: false,
1619 swarm_strategy: "auto".to_string(),
1620 swarm_max_subagents: 8,
1621 swarm_max_steps: 100,
1622 swarm_subagent_timeout_secs: 300,
1623 model: Some("openai-codex/gpt-5.1-codex".to_string()),
1624 json: false,
1625 no_s3: false,
1626 };
1627
1628 let execution_outcome = ExecutionOutcome {
1629 detail: "run execution completed".to_string(),
1630 changed_files: vec!["src/forage/mod.rs".to_string()],
1631 quality_gates_passed: true,
1632 };
1633 record_execution_success_to_okr(&repo, &item, &args, &execution_outcome, 1)
1634 .await
1635 .expect("record success");
1636
1637 let saved = repo
1638 .get_okr(okr_id)
1639 .await
1640 .expect("read okr")
1641 .expect("okr exists");
1642 let saved_kr = saved
1643 .key_results
1644 .into_iter()
1645 .find(|k| k.id == kr_id)
1646 .expect("kr exists");
1647 assert!(saved_kr.current_value > 0.0);
1648 assert_eq!(saved_kr.outcomes.len(), 1);
1649 assert!(
1650 saved_kr.outcomes[0]
1651 .evidence
1652 .iter()
1653 .any(|entry| entry.starts_with("engine:run"))
1654 );
1655 }
1656
1657 #[tokio::test]
1658 async fn swarm_success_without_file_evidence_does_not_increment_progress() {
1659 let dir = tempdir().expect("create tempdir");
1660 let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1661
1662 let mut okr = Okr::new("Autonomous Business-Aligned Execution", "Test objective");
1663 okr.status = OkrStatus::Active;
1664 let mut kr = KeyResult::new(okr.id, "KR1", 100.0, "%");
1665 kr.update_progress(10.0);
1666 let kr_id = kr.id;
1667 okr.add_key_result(kr);
1668 let okr_id = okr.id;
1669 let _ = repo.create_okr(okr).await.expect("create okr");
1670
1671 let item = ForageOpportunity {
1672 score: 1.35,
1673 okr_id,
1674 okr_title: "Autonomous Business-Aligned Execution".to_string(),
1675 okr_status: OkrStatus::Active,
1676 key_result_id: kr_id,
1677 key_result_title: "KR1".to_string(),
1678 progress: 0.10,
1679 remaining: 0.90,
1680 target_date: None,
1681 moonshot_alignment: 0.0,
1682 moonshot_hits: Vec::new(),
1683 prompt: "test prompt".to_string(),
1684 };
1685 let args = ForageArgs {
1686 top: 3,
1687 loop_mode: false,
1688 interval_secs: 120,
1689 max_cycles: 1,
1690 execute: true,
1691 moonshots: Vec::new(),
1692 moonshot_file: None,
1693 moonshot_required: false,
1694 moonshot_min_alignment: 0.10,
1695 execution_engine: "swarm".to_string(),
1696 run_timeout_secs: 900,
1697 fail_fast: false,
1698 swarm_strategy: "auto".to_string(),
1699 swarm_max_subagents: 8,
1700 swarm_max_steps: 100,
1701 swarm_subagent_timeout_secs: 300,
1702 model: Some("openai-codex/gpt-5.1-codex".to_string()),
1703 json: false,
1704 no_s3: false,
1705 };
1706 let execution_outcome = ExecutionOutcome {
1707 detail: "swarm execution completed".to_string(),
1708 changed_files: Vec::new(),
1709 quality_gates_passed: true,
1710 };
1711 record_execution_success_to_okr(&repo, &item, &args, &execution_outcome, 2)
1712 .await
1713 .expect("record success");
1714
1715 let saved = repo
1716 .get_okr(okr_id)
1717 .await
1718 .expect("read okr")
1719 .expect("okr exists");
1720 let saved_kr = saved
1721 .key_results
1722 .into_iter()
1723 .find(|k| k.id == kr_id)
1724 .expect("kr exists");
1725 assert_eq!(saved_kr.current_value, 10.0);
1726 assert_eq!(saved_kr.outcomes.len(), 1);
1727 assert!(
1728 saved_kr.outcomes[0]
1729 .evidence
1730 .iter()
1731 .any(|entry| entry == "concrete_file_evidence:false")
1732 );
1733 }
1734
1735 #[tokio::test]
1736 async fn seed_default_okr_populates_empty_repo() {
1737 let dir = tempdir().expect("create tempdir");
1738 let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1739
1740 seed_default_okr_if_empty(&repo)
1741 .await
1742 .expect("seed should succeed");
1743
1744 let okrs = repo.list_okrs().await.expect("list okrs");
1745 assert_eq!(okrs.len(), 1);
1746 assert_eq!(
1747 okrs[0].title,
1748 "Mission: Autonomous Business-Aligned Execution"
1749 );
1750 assert_eq!(okrs[0].status, OkrStatus::Active);
1751 assert_eq!(okrs[0].key_results.len(), 3);
1752 }
1753
1754 #[tokio::test]
1755 async fn seed_default_okr_is_noop_when_repo_not_empty() {
1756 let dir = tempdir().expect("create tempdir");
1757 let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1758
1759 let mut existing = Okr::new("Existing Objective", "Do not overwrite");
1760 existing.status = OkrStatus::Active;
1761 existing.add_key_result(KeyResult::new(existing.id, "KR1", 100.0, "%"));
1762 let _ = repo.create_okr(existing).await.expect("create existing");
1763
1764 seed_default_okr_if_empty(&repo)
1765 .await
1766 .expect("seed should succeed");
1767
1768 let okrs = repo.list_okrs().await.expect("list okrs");
1769 assert_eq!(okrs.len(), 1);
1770 assert_eq!(okrs[0].title, "Existing Objective");
1771 }
1772
1773 #[tokio::test]
1774 async fn moonshot_seed_creates_okr_when_no_opportunities_exist() {
1775 let dir = tempdir().expect("create tempdir");
1776 let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1777
1778 let mut completed = Okr::new("Completed Objective", "Already done");
1780 completed.status = OkrStatus::Completed;
1781 let mut kr = KeyResult::new(completed.id, "KR done", 100.0, "%");
1782 kr.update_progress(100.0);
1783 completed.add_key_result(kr);
1784 let _ = repo.create_okr(completed).await.expect("create completed");
1785
1786 let rubric = MoonshotRubric {
1787 goals: vec![
1788 "Automate customer acquisition end-to-end".to_string(),
1789 "Funnel conversion replaces manual sales".to_string(),
1790 ],
1791 required: true,
1792 min_alignment: 0.2,
1793 };
1794
1795 let seeded_id = seed_moonshot_okr_if_no_opportunities(&repo, &rubric)
1796 .await
1797 .expect("seed should succeed");
1798 assert!(seeded_id.is_some(), "expected moonshot-derived seed OKR");
1799 let seeded_id = seeded_id.expect("seeded id");
1800
1801 let okrs = repo.list_okrs().await.expect("list okrs");
1802 let seeded = okrs
1803 .iter()
1804 .find(|o| o.id == seeded_id)
1805 .expect("seeded okr exists");
1806 assert_eq!(seeded.status, OkrStatus::Active);
1807 assert_eq!(
1808 seeded.title,
1809 "Mission: Moonshot-Derived Autonomous Execution"
1810 );
1811 assert!(!seeded.key_results.is_empty());
1812 }
1813
1814 #[tokio::test]
1815 async fn moonshot_seed_is_noop_when_open_moonshot_seed_exists() {
1816 let dir = tempdir().expect("create tempdir");
1817 let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1818
1819 let rubric = MoonshotRubric {
1820 goals: vec!["Tech stack is the moat".to_string()],
1821 required: true,
1822 min_alignment: 0.2,
1823 };
1824 let first = seed_moonshot_okr_if_no_opportunities(&repo, &rubric)
1825 .await
1826 .expect("first seed should succeed");
1827 assert!(first.is_some());
1828
1829 let second = seed_moonshot_okr_if_no_opportunities(&repo, &rubric)
1830 .await
1831 .expect("second seed should succeed");
1832 assert!(
1833 second.is_none(),
1834 "should not duplicate active moonshot seed"
1835 );
1836 }
1837}