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