Skip to main content

codetether_agent/forage/
mod.rs

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    // S3 archival is optional when --no-s3 is specified
142    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        // Only check S3 health if S3 is required
200        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    // If there is already an open moonshot-derived objective with incomplete work,
501    // avoid continuously creating new ones.
502    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    // Only increment progress if:
636    // 1. File evidence exists (or not required for "run" engine)
637    // 2. Quality gates passed (cargo check/test succeeded)
638    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    // Run quality gates to verify the changes work
776    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) // Treat execution errors as quality failure
783        }
784    };
785
786    Ok(ExecutionOutcome {
787        detail: final_detail,
788        changed_files,
789        quality_gates_passed: quality_passed,
790    })
791}
792
793/// Run quality gates (cargo check/test) to verify changes work
794async fn run_quality_gates(changed_files: &[String]) -> Result<(String, bool)> {
795    // Only run quality gates if there are Rust files changed
796    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    // Run cargo check (fast type checking)
809    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    // Run cargo test (if check passed or for important changes)
838    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
951/// Execute opportunity using the Ralph PRD-driven autonomous loop (go engine)
952async 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    // Load provider registry and get a provider
965    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    // Load the OKR to get full context for PRD generation
974    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    // Create an OKR run for this execution
984    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    // Build the task from the opportunity
992    let task = item.prompt.clone();
993
994    // Execute the Ralph PRD-driven autonomous loop
995    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,   // max_iterations
1004            None, // bus - could pass bus here for inter-iteration learning
1005            3,    // max_concurrent_stories
1006            None, // registry - passed via RalphLoop.with_registry if needed
1007        ),
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            // Update run status to failed
1035            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        // Completed objective should not produce forage opportunities.
1779        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}