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        auto_continue_until: None,
885        branches: 1,
886        strategies: Vec::new(),
887    };
888    let timeout_secs = args.run_timeout_secs.clamp(30, 86_400);
889    match tokio::time::timeout(
890        Duration::from_secs(timeout_secs),
891        crate::cli::run::execute(run_args),
892    )
893    .await
894    {
895        Ok(Ok(())) => Ok("run execution completed".to_string()),
896        Ok(Err(err)) => Err(err),
897        Err(_) => anyhow::bail!("run execution timed out after {timeout_secs}s"),
898    }
899}
900
901async fn execute_opportunity_with_swarm(
902    item: &ForageOpportunity,
903    args: &ForageArgs,
904) -> Result<String> {
905    let timeout_secs = args.run_timeout_secs.clamp(30, 86_400);
906    let swarm_config = build_swarm_config(args);
907    let executor = SwarmExecutor::new(swarm_config);
908    let strategy = parse_swarm_strategy(&args.swarm_strategy);
909
910    match tokio::time::timeout(
911        Duration::from_secs(timeout_secs),
912        executor.execute(&item.prompt, strategy),
913    )
914    .await
915    {
916        Ok(Ok(result)) => {
917            if result.success {
918                Ok(format!(
919                    "swarm execution completed (subagents_spawned={}, completed={}, failed={}, retries={})",
920                    result.stats.subagents_spawned,
921                    result.stats.subagents_completed,
922                    result.stats.subagents_failed,
923                    result
924                        .subtask_results
925                        .iter()
926                        .map(|r| r.retry_count)
927                        .sum::<u32>() as usize
928                ))
929            } else {
930                let error = result.error.unwrap_or_else(|| {
931                    format!(
932                        "swarm reported failure (failed_subtasks={}, total_subtasks={})",
933                        result.stats.subagents_failed, result.stats.subagents_spawned
934                    )
935                });
936                anyhow::bail!(error);
937            }
938        }
939        Ok(Err(err)) => Err(err),
940        Err(_) => anyhow::bail!("swarm execution timed out after {timeout_secs}s"),
941    }
942}
943
944fn build_swarm_config(args: &ForageArgs) -> SwarmConfig {
945    SwarmConfig {
946        max_subagents: args.swarm_max_subagents.max(1),
947        max_steps_per_subagent: args.swarm_max_steps.max(1),
948        subagent_timeout_secs: args.swarm_subagent_timeout_secs.clamp(30, 86_400),
949        model: args.model.clone(),
950        execution_mode: ExecutionMode::LocalThread,
951        ..Default::default()
952    }
953}
954
955/// Execute opportunity using the Ralph PRD-driven autonomous loop (go engine)
956async fn execute_opportunity_with_go(
957    item: &ForageOpportunity,
958    args: &ForageArgs,
959) -> Result<String> {
960    use crate::cli::go_ralph::execute_go_ralph;
961
962    let timeout_secs = args.run_timeout_secs.clamp(30, 86_400);
963    let model = args
964        .model
965        .clone()
966        .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string());
967
968    // Load provider registry and get a provider
969    let registry = ProviderRegistry::from_vault()
970        .await
971        .context("Failed to load provider registry for go engine")?;
972
973    let (provider, resolved_model) = registry
974        .resolve_model(&model)
975        .with_context(|| format!("Failed to resolve model '{}' for go engine", model))?;
976
977    // Load the OKR to get full context for PRD generation
978    let repo = OkrRepository::from_config()
979        .await
980        .context("Failed to load OKR repository")?;
981
982    let mut okr = repo
983        .get_okr(item.okr_id)
984        .await?
985        .with_context(|| format!("OKR {} not found", item.okr_id))?;
986
987    // Create an OKR run for this execution
988    let mut okr_run = OkrRun::new(item.okr_id, format!("forage-cycle-{}", item.key_result_id));
989    okr_run.submit_for_approval()?;
990    okr_run.record_decision(crate::okr::ApprovalDecision::approve(
991        okr_run.id,
992        "Auto-approved from forage execution",
993    ));
994
995    // Build the task from the opportunity
996    let task = item.prompt.clone();
997
998    // Execute the Ralph PRD-driven autonomous loop
999    match tokio::time::timeout(
1000        Duration::from_secs(timeout_secs),
1001        execute_go_ralph(
1002            &task,
1003            &mut okr,
1004            &mut okr_run,
1005            provider,
1006            &resolved_model,
1007            10,   // max_iterations
1008            None, // bus - could pass bus here for inter-iteration learning
1009            3,    // max_concurrent_stories
1010            None, // registry - passed via RalphLoop.with_registry if needed
1011        ),
1012    )
1013    .await
1014    {
1015        Ok(Ok(result)) => {
1016            if result.all_passed {
1017                Ok(format!(
1018                    "go execution completed - all {}/{} stories passed (iterations: {}/{}, branch: {})",
1019                    result.passed,
1020                    result.total,
1021                    result.iterations,
1022                    result.max_iterations,
1023                    result.feature_branch
1024                ))
1025            } else {
1026                Ok(format!(
1027                    "go execution completed - {}/{} stories passed (iterations: {}/{}, branch: {}, status: {:?})",
1028                    result.passed,
1029                    result.total,
1030                    result.iterations,
1031                    result.max_iterations,
1032                    result.feature_branch,
1033                    result.status
1034                ))
1035            }
1036        }
1037        Ok(Err(err)) => {
1038            // Update run status to failed
1039            okr_run.status = OkrRunStatus::Failed;
1040            Err(err).context("go execution failed")
1041        }
1042        Err(_) => anyhow::bail!("go execution timed out after {timeout_secs}s"),
1043    }
1044}
1045
1046fn parse_swarm_strategy(value: &str) -> DecompositionStrategy {
1047    match value {
1048        "domain" => DecompositionStrategy::ByDomain,
1049        "data" => DecompositionStrategy::ByData,
1050        "stage" => DecompositionStrategy::ByStage,
1051        "none" => DecompositionStrategy::None,
1052        _ => DecompositionStrategy::Automatic,
1053    }
1054}
1055
1056async fn ensure_audit_log_initialized() {
1057    if audit::try_audit_log().is_some() {
1058        return;
1059    }
1060
1061    let default_sink = crate::config::Config::data_dir()
1062        .map(|base| base.join("audit"))
1063        .map(|audit_dir| {
1064            let _ = std::fs::create_dir_all(&audit_dir);
1065            audit_dir.join("forage_audit.jsonl")
1066        });
1067    let log = if std::env::var("CODETETHER_AUDIT_LOG_PATH").is_ok() {
1068        AuditLog::from_env()
1069    } else {
1070        AuditLog::new(10_000, default_sink)
1071    };
1072    let _ = audit::init_audit_log(log);
1073}
1074
1075async fn log_audit(
1076    category: AuditCategory,
1077    action: &str,
1078    outcome: AuditOutcome,
1079    detail: Option<serde_json::Value>,
1080    okr_id: Option<Uuid>,
1081    session_id: Option<String>,
1082) {
1083    if let Some(audit_log) = audit::try_audit_log() {
1084        audit_log
1085            .log_with_correlation(
1086                category,
1087                action,
1088                outcome,
1089                Some("forage-runtime".to_string()),
1090                detail,
1091                okr_id.map(|id| id.to_string()),
1092                None,
1093                None,
1094                session_id,
1095            )
1096            .await;
1097    }
1098}
1099
1100fn flush_bus_observer(observer: &mut BusHandle, cycle: usize, json_mode: bool) {
1101    if json_mode {
1102        return;
1103    }
1104
1105    let mut shown = 0usize;
1106    while let Some(env) = observer.try_recv() {
1107        if shown >= 12 {
1108            break;
1109        }
1110        let label = match &env.message {
1111            BusMessage::AgentReady { .. } => "agent_ready",
1112            BusMessage::AgentShutdown { .. } => "agent_shutdown",
1113            BusMessage::TaskUpdate { .. } => "task_update",
1114            BusMessage::SharedResult { .. } => "shared_result",
1115            BusMessage::AgentMessage { .. } => "agent_message",
1116            _ => "other",
1117        };
1118        println!(
1119            "   [bus cycle {cycle}] {} :: topic={} sender={}",
1120            label, env.topic, env.sender_id
1121        );
1122        shown = shown.saturating_add(1);
1123    }
1124}
1125
1126async fn load_moonshot_rubric(args: &ForageArgs) -> Result<MoonshotRubric> {
1127    let mut goals = args
1128        .moonshots
1129        .iter()
1130        .map(|s| s.trim())
1131        .filter(|s| !s.is_empty())
1132        .map(ToString::to_string)
1133        .collect::<Vec<_>>();
1134
1135    if let Some(path) = &args.moonshot_file {
1136        let content = tokio::fs::read_to_string(path)
1137            .await
1138            .with_context(|| format!("Failed to read moonshot file: {}", path.display()))?;
1139
1140        if let Ok(json_list) = serde_json::from_str::<Vec<String>>(&content) {
1141            goals.extend(
1142                json_list
1143                    .into_iter()
1144                    .map(|s| s.trim().to_string())
1145                    .filter(|s| !s.is_empty()),
1146            );
1147        } else {
1148            goals.extend(
1149                content
1150                    .lines()
1151                    .map(str::trim)
1152                    .filter(|line| !line.is_empty() && !line.starts_with('#'))
1153                    .map(ToString::to_string),
1154            );
1155        }
1156    }
1157
1158    let mut deduped = Vec::new();
1159    let mut seen = HashSet::new();
1160    for goal in goals {
1161        let key = goal.to_ascii_lowercase();
1162        if seen.insert(key) {
1163            deduped.push(goal);
1164        }
1165    }
1166
1167    Ok(MoonshotRubric {
1168        goals: deduped,
1169        required: args.moonshot_required,
1170        min_alignment: args.moonshot_min_alignment.clamp(0.0, 1.0),
1171    })
1172}
1173
1174async fn build_opportunities(
1175    repo: &OkrRepository,
1176    moonshots: &MoonshotRubric,
1177) -> Result<Vec<ForageOpportunity>> {
1178    let okrs = repo.list_okrs().await?;
1179    Ok(collect_opportunities_with_rubric(&okrs, moonshots))
1180}
1181
1182#[cfg(test)]
1183fn collect_opportunities(okrs: &[Okr]) -> Vec<ForageOpportunity> {
1184    collect_opportunities_with_rubric(okrs, &MoonshotRubric::default())
1185}
1186
1187fn collect_opportunities_with_rubric(
1188    okrs: &[Okr],
1189    moonshots: &MoonshotRubric,
1190) -> Vec<ForageOpportunity> {
1191    let now = Utc::now();
1192    let mut items = Vec::new();
1193
1194    for okr in okrs {
1195        let status_weight = status_weight(okr.status);
1196        if status_weight <= 0.0 {
1197            continue;
1198        }
1199
1200        for kr in &okr.key_results {
1201            let progress = kr.progress().clamp(0.0, 1.0);
1202            if progress >= 1.0 {
1203                continue;
1204            }
1205            let remaining = (1.0 - progress).clamp(0.0, 1.0);
1206            let urgency_bonus = urgency_bonus(okr.target_date, now);
1207            let alignment_context = format!(
1208                "{} {} {} {}",
1209                okr.title, okr.description, kr.title, kr.description
1210            );
1211            let moonshot_alignment = moonshot_alignment_score(&alignment_context, &moonshots.goals);
1212            if moonshots.required && moonshot_alignment < moonshots.min_alignment {
1213                continue;
1214            }
1215            let moonshot_hits = matching_moonshots(&alignment_context, &moonshots.goals);
1216            let moonshot_bonus = if moonshots.goals.is_empty() {
1217                0.0
1218            } else {
1219                (moonshot_alignment * 0.5).min(0.5)
1220            };
1221            let score = tetherscript_score::apply(
1222                okr,
1223                kr,
1224                (remaining * status_weight) + urgency_bonus + moonshot_bonus,
1225                remaining,
1226                moonshot_alignment,
1227            );
1228            let prompt = build_execution_prompt(okr, kr, moonshots, moonshot_alignment);
1229
1230            items.push(ForageOpportunity {
1231                score,
1232                okr_id: okr.id,
1233                okr_title: okr.title.clone(),
1234                okr_status: okr.status,
1235                key_result_id: kr.id,
1236                key_result_title: kr.title.clone(),
1237                progress,
1238                remaining,
1239                target_date: okr.target_date,
1240                moonshot_alignment,
1241                moonshot_hits,
1242                prompt,
1243            });
1244        }
1245    }
1246
1247    items.sort_by(
1248        |a, b| match b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal) {
1249            Ordering::Equal => b
1250                .remaining
1251                .partial_cmp(&a.remaining)
1252                .unwrap_or(Ordering::Equal),
1253            other => other,
1254        },
1255    );
1256    items
1257}
1258
1259fn tokenize_for_alignment(value: &str) -> HashSet<String> {
1260    value
1261        .split(|c: char| !c.is_ascii_alphanumeric())
1262        .map(|s| s.trim().to_ascii_lowercase())
1263        .filter(|s| s.len() >= 4)
1264        .collect()
1265}
1266
1267fn moonshot_alignment_score(context: &str, goals: &[String]) -> f64 {
1268    if goals.is_empty() {
1269        return 0.0;
1270    }
1271
1272    let context_tokens = tokenize_for_alignment(context);
1273    if context_tokens.is_empty() {
1274        return 0.0;
1275    }
1276
1277    goals
1278        .iter()
1279        .map(|goal| {
1280            let goal_tokens = tokenize_for_alignment(goal);
1281            if goal_tokens.is_empty() {
1282                return 0.0;
1283            }
1284            let overlap = goal_tokens.intersection(&context_tokens).count() as f64;
1285            overlap / goal_tokens.len() as f64
1286        })
1287        .fold(0.0, f64::max)
1288}
1289
1290fn matching_moonshots(context: &str, goals: &[String]) -> Vec<String> {
1291    if goals.is_empty() {
1292        return Vec::new();
1293    }
1294    let context_tokens = tokenize_for_alignment(context);
1295    let mut hits = goals
1296        .iter()
1297        .filter_map(|goal| {
1298            let goal_tokens = tokenize_for_alignment(goal);
1299            if goal_tokens.is_empty() {
1300                return None;
1301            }
1302            let overlap = goal_tokens.intersection(&context_tokens).count();
1303            if overlap == 0 {
1304                None
1305            } else {
1306                Some((overlap, goal.clone()))
1307            }
1308        })
1309        .collect::<Vec<_>>();
1310    hits.sort_by(|a, b| b.0.cmp(&a.0));
1311    hits.into_iter().take(3).map(|(_, goal)| goal).collect()
1312}
1313
1314fn status_weight(status: OkrStatus) -> f64 {
1315    match status {
1316        OkrStatus::Active => 1.0,
1317        OkrStatus::Draft => 0.65,
1318        OkrStatus::OnHold => 0.35,
1319        OkrStatus::Completed | OkrStatus::Cancelled => 0.0,
1320    }
1321}
1322
1323fn urgency_bonus(target_date: Option<DateTime<Utc>>, now: DateTime<Utc>) -> f64 {
1324    let Some(target_date) = target_date else {
1325        return 0.0;
1326    };
1327
1328    if target_date <= now {
1329        return 0.35;
1330    }
1331
1332    let days = (target_date - now).num_days();
1333    if days <= 7 {
1334        0.25
1335    } else if days <= 30 {
1336        0.1
1337    } else {
1338        0.0
1339    }
1340}
1341
1342const MAX_PROMPT_FIELD_CHARS: usize = 1_200;
1343
1344fn normalize_prompt_field(value: &str, max_chars: usize) -> String {
1345    let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
1346    let normalized_max = max_chars.max(32);
1347    if compact.chars().count() <= normalized_max {
1348        return compact;
1349    }
1350
1351    let mut truncated = compact.chars().take(normalized_max).collect::<String>();
1352    truncated.push_str(" ...(truncated)");
1353    truncated
1354}
1355
1356fn build_execution_prompt(
1357    okr: &Okr,
1358    kr: &KeyResult,
1359    moonshots: &MoonshotRubric,
1360    moonshot_alignment: f64,
1361) -> String {
1362    let objective = normalize_prompt_field(&okr.title, MAX_PROMPT_FIELD_CHARS);
1363    let objective_description = normalize_prompt_field(&okr.description, MAX_PROMPT_FIELD_CHARS);
1364    let key_result = normalize_prompt_field(&kr.title, MAX_PROMPT_FIELD_CHARS);
1365    let key_result_description = normalize_prompt_field(&kr.description, MAX_PROMPT_FIELD_CHARS);
1366    let moonshot_section = if moonshots.goals.is_empty() {
1367        String::new()
1368    } else {
1369        format!(
1370            "\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.",
1371            moonshots
1372                .goals
1373                .iter()
1374                .map(|g| normalize_prompt_field(g, 160))
1375                .collect::<Vec<_>>()
1376                .join("\n- "),
1377            moonshot_alignment * 100.0
1378        )
1379    };
1380
1381    format!(
1382        "Business-goal execution task.\n\
1383Objective: {}\n\
1384Objective Description: {}\n\
1385Key Result: {}\n\
1386KR Description: {}\n\
1387Current: {:.3} {} | Target: {:.3} {}\n\n\
1388Execute one concrete, behavior-preserving code change that measurably advances this key result. \
1389Use tools, validate the change, and report exact evidence tied to the KR.\n\
1390Focus on local repository changes first; do not do broad web research unless required by the KR.\n\
1391Return exact changed file paths and at least one verification command result.{}",
1392        objective,
1393        objective_description,
1394        key_result,
1395        key_result_description,
1396        kr.current_value,
1397        kr.unit,
1398        kr.target_value,
1399        kr.unit,
1400        moonshot_section
1401    )
1402}
1403
1404impl ForageOpportunity {
1405    fn okr_status_label(&self) -> &'static str {
1406        match self.okr_status {
1407            OkrStatus::Draft => "draft",
1408            OkrStatus::Active => "active",
1409            OkrStatus::Completed => "completed",
1410            OkrStatus::Cancelled => "cancelled",
1411            OkrStatus::OnHold => "on_hold",
1412        }
1413    }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::{
1419        ExecutionOutcome, ForageOpportunity, MoonshotRubric, build_swarm_config,
1420        collect_opportunities, collect_opportunities_with_rubric, normalize_prompt_field,
1421        record_execution_success_to_okr, seed_default_okr_if_empty,
1422        seed_moonshot_okr_if_no_opportunities, status_weight, success_progress_increment_ratio,
1423        urgency_bonus,
1424    };
1425    use crate::cli::ForageArgs;
1426    use crate::okr::{KeyResult, Okr, OkrStatus};
1427    use chrono::{Duration, Utc};
1428    use tempfile::tempdir;
1429    use uuid::Uuid;
1430
1431    #[test]
1432    fn status_weight_prioritizes_active_okrs() {
1433        assert!(status_weight(OkrStatus::Active) > status_weight(OkrStatus::Draft));
1434        assert!(status_weight(OkrStatus::Draft) > status_weight(OkrStatus::OnHold));
1435        assert_eq!(status_weight(OkrStatus::Completed), 0.0);
1436    }
1437
1438    #[test]
1439    fn urgency_bonus_increases_for_due_dates() {
1440        let now = Utc::now();
1441        let overdue = urgency_bonus(Some(now - Duration::days(1)), now);
1442        let soon = urgency_bonus(Some(now + Duration::days(3)), now);
1443        let later = urgency_bonus(Some(now + Duration::days(45)), now);
1444        assert!(overdue > soon);
1445        assert!(soon > later);
1446    }
1447
1448    #[test]
1449    fn collect_opportunities_skips_complete_or_cancelled_work() {
1450        let mut okr = Okr::new("Ship growth loop", "Increase retained users");
1451        okr.status = OkrStatus::Cancelled;
1452        let mut kr = KeyResult::new(okr.id, "Retained users", 100.0, "%");
1453        kr.update_progress(10.0);
1454        okr.add_key_result(kr);
1455
1456        let items = collect_opportunities(&[okr]);
1457        assert!(items.is_empty());
1458    }
1459
1460    #[test]
1461    fn collect_opportunities_ranks_remaining_work() {
1462        let mut okr = Okr::new("Ship growth loop", "Increase retained users");
1463        okr.status = OkrStatus::Active;
1464
1465        let mut kr_low = KeyResult::new(okr.id, "KR Low Remaining", 100.0, "%");
1466        kr_low.update_progress(80.0);
1467        let mut kr_high = KeyResult::new(okr.id, "KR High Remaining", 100.0, "%");
1468        kr_high.update_progress(10.0);
1469        okr.add_key_result(kr_low);
1470        okr.add_key_result(kr_high);
1471
1472        let items = collect_opportunities(&[okr]);
1473        assert_eq!(items.len(), 2);
1474        assert_eq!(items[0].key_result_title, "KR High Remaining");
1475    }
1476
1477    #[test]
1478    fn moonshot_rubric_filters_low_alignment_work() {
1479        let mut okr = Okr::new(
1480            "Improve parser latency",
1481            "Reduce p95 latency for parser pipeline",
1482        );
1483        okr.status = OkrStatus::Active;
1484        let mut kr = KeyResult::new(okr.id, "Parser p95 under 50ms", 100.0, "%");
1485        kr.update_progress(10.0);
1486        okr.add_key_result(kr);
1487
1488        let rubric = MoonshotRubric {
1489            goals: vec!["eliminate billing fraud globally".to_string()],
1490            required: true,
1491            min_alignment: 0.4,
1492        };
1493
1494        let items = collect_opportunities_with_rubric(&[okr], &rubric);
1495        assert!(
1496            items.is_empty(),
1497            "non-aligned work should be filtered out when moonshot is required"
1498        );
1499    }
1500
1501    #[test]
1502    fn forage_timeout_bounds_are_clamped() {
1503        let low = 5u64.clamp(30, 86_400);
1504        let high = 999_999u64.clamp(30, 86_400);
1505        assert_eq!(low, 30);
1506        assert_eq!(high, 86_400);
1507    }
1508
1509    #[test]
1510    fn swarm_config_does_not_force_legacy_model_fallback() {
1511        let args = ForageArgs {
1512            top: 3,
1513            loop_mode: false,
1514            interval_secs: 120,
1515            max_cycles: 1,
1516            execute: true,
1517            moonshots: Vec::new(),
1518            moonshot_file: None,
1519            moonshot_required: false,
1520            moonshot_min_alignment: 0.10,
1521            execution_engine: "swarm".to_string(),
1522            run_timeout_secs: 900,
1523            fail_fast: false,
1524            swarm_strategy: "auto".to_string(),
1525            swarm_max_subagents: 8,
1526            swarm_max_steps: 100,
1527            swarm_subagent_timeout_secs: 300,
1528            model: None,
1529            json: false,
1530            no_s3: false,
1531        };
1532
1533        let config = build_swarm_config(&args);
1534        assert!(config.model.is_none());
1535    }
1536
1537    #[test]
1538    fn swarm_config_preserves_explicit_model_override() {
1539        let args = ForageArgs {
1540            top: 3,
1541            loop_mode: false,
1542            interval_secs: 120,
1543            max_cycles: 1,
1544            execute: true,
1545            moonshots: Vec::new(),
1546            moonshot_file: None,
1547            moonshot_required: false,
1548            moonshot_min_alignment: 0.10,
1549            execution_engine: "swarm".to_string(),
1550            run_timeout_secs: 900,
1551            fail_fast: false,
1552            swarm_strategy: "auto".to_string(),
1553            swarm_max_subagents: 8,
1554            swarm_max_steps: 100,
1555            swarm_subagent_timeout_secs: 300,
1556            model: Some("openai-codex/gpt-5-mini".to_string()),
1557            json: false,
1558            no_s3: false,
1559        };
1560
1561        let config = build_swarm_config(&args);
1562        assert_eq!(config.model.as_deref(), Some("openai-codex/gpt-5-mini"));
1563    }
1564
1565    #[test]
1566    fn normalize_prompt_field_compacts_and_truncates() {
1567        let input = "alpha   beta\n\n gamma    delta";
1568        assert_eq!(normalize_prompt_field(input, 128), "alpha beta gamma delta");
1569
1570        let long = "x".repeat(400);
1571        let normalized = normalize_prompt_field(&long, 64);
1572        assert!(normalized.ends_with("...(truncated)"));
1573        assert!(normalized.len() > 64);
1574    }
1575
1576    #[test]
1577    fn success_progress_increment_ratio_is_bounded() {
1578        let mut kr = KeyResult::new(Uuid::new_v4(), "KR", 100.0, "%");
1579        kr.update_progress(0.0);
1580        let high_remaining = success_progress_increment_ratio(&kr);
1581        assert!((high_remaining - 0.15).abs() < f64::EPSILON);
1582
1583        kr.update_progress(95.0);
1584        let low_remaining = success_progress_increment_ratio(&kr);
1585        assert!((low_remaining - 0.05).abs() < f64::EPSILON);
1586    }
1587
1588    #[tokio::test]
1589    async fn record_execution_success_updates_kr_progress_and_evidence() {
1590        let dir = tempdir().expect("create tempdir");
1591        let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1592
1593        let mut okr = Okr::new("Autonomous Business-Aligned Execution", "Test objective");
1594        okr.status = OkrStatus::Active;
1595        let mut kr = KeyResult::new(okr.id, "KR1", 100.0, "%");
1596        kr.update_progress(0.0);
1597        let kr_id = kr.id;
1598        okr.add_key_result(kr);
1599        let okr_id = okr.id;
1600        let _ = repo.create_okr(okr).await.expect("create okr");
1601
1602        let item = ForageOpportunity {
1603            score: 1.35,
1604            okr_id,
1605            okr_title: "Autonomous Business-Aligned Execution".to_string(),
1606            okr_status: OkrStatus::Active,
1607            key_result_id: kr_id,
1608            key_result_title: "KR1".to_string(),
1609            progress: 0.0,
1610            remaining: 1.0,
1611            target_date: None,
1612            moonshot_alignment: 0.0,
1613            moonshot_hits: Vec::new(),
1614            prompt: "test prompt".to_string(),
1615        };
1616        let args = ForageArgs {
1617            top: 3,
1618            loop_mode: false,
1619            interval_secs: 120,
1620            max_cycles: 1,
1621            execute: true,
1622            moonshots: Vec::new(),
1623            moonshot_file: None,
1624            moonshot_required: false,
1625            moonshot_min_alignment: 0.10,
1626            execution_engine: "run".to_string(),
1627            run_timeout_secs: 900,
1628            fail_fast: false,
1629            swarm_strategy: "auto".to_string(),
1630            swarm_max_subagents: 8,
1631            swarm_max_steps: 100,
1632            swarm_subagent_timeout_secs: 300,
1633            model: Some("openai-codex/gpt-5.1-codex".to_string()),
1634            json: false,
1635            no_s3: false,
1636        };
1637
1638        let execution_outcome = ExecutionOutcome {
1639            detail: "run execution completed".to_string(),
1640            changed_files: vec!["src/forage/mod.rs".to_string()],
1641            quality_gates_passed: true,
1642        };
1643        record_execution_success_to_okr(&repo, &item, &args, &execution_outcome, 1)
1644            .await
1645            .expect("record success");
1646
1647        let saved = repo
1648            .get_okr(okr_id)
1649            .await
1650            .expect("read okr")
1651            .expect("okr exists");
1652        let saved_kr = saved
1653            .key_results
1654            .into_iter()
1655            .find(|k| k.id == kr_id)
1656            .expect("kr exists");
1657        assert!(saved_kr.current_value > 0.0);
1658        assert_eq!(saved_kr.outcomes.len(), 1);
1659        assert!(
1660            saved_kr.outcomes[0]
1661                .evidence
1662                .iter()
1663                .any(|entry| entry.starts_with("engine:run"))
1664        );
1665    }
1666
1667    #[tokio::test]
1668    async fn swarm_success_without_file_evidence_does_not_increment_progress() {
1669        let dir = tempdir().expect("create tempdir");
1670        let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1671
1672        let mut okr = Okr::new("Autonomous Business-Aligned Execution", "Test objective");
1673        okr.status = OkrStatus::Active;
1674        let mut kr = KeyResult::new(okr.id, "KR1", 100.0, "%");
1675        kr.update_progress(10.0);
1676        let kr_id = kr.id;
1677        okr.add_key_result(kr);
1678        let okr_id = okr.id;
1679        let _ = repo.create_okr(okr).await.expect("create okr");
1680
1681        let item = ForageOpportunity {
1682            score: 1.35,
1683            okr_id,
1684            okr_title: "Autonomous Business-Aligned Execution".to_string(),
1685            okr_status: OkrStatus::Active,
1686            key_result_id: kr_id,
1687            key_result_title: "KR1".to_string(),
1688            progress: 0.10,
1689            remaining: 0.90,
1690            target_date: None,
1691            moonshot_alignment: 0.0,
1692            moonshot_hits: Vec::new(),
1693            prompt: "test prompt".to_string(),
1694        };
1695        let args = ForageArgs {
1696            top: 3,
1697            loop_mode: false,
1698            interval_secs: 120,
1699            max_cycles: 1,
1700            execute: true,
1701            moonshots: Vec::new(),
1702            moonshot_file: None,
1703            moonshot_required: false,
1704            moonshot_min_alignment: 0.10,
1705            execution_engine: "swarm".to_string(),
1706            run_timeout_secs: 900,
1707            fail_fast: false,
1708            swarm_strategy: "auto".to_string(),
1709            swarm_max_subagents: 8,
1710            swarm_max_steps: 100,
1711            swarm_subagent_timeout_secs: 300,
1712            model: Some("openai-codex/gpt-5.1-codex".to_string()),
1713            json: false,
1714            no_s3: false,
1715        };
1716        let execution_outcome = ExecutionOutcome {
1717            detail: "swarm execution completed".to_string(),
1718            changed_files: Vec::new(),
1719            quality_gates_passed: true,
1720        };
1721        record_execution_success_to_okr(&repo, &item, &args, &execution_outcome, 2)
1722            .await
1723            .expect("record success");
1724
1725        let saved = repo
1726            .get_okr(okr_id)
1727            .await
1728            .expect("read okr")
1729            .expect("okr exists");
1730        let saved_kr = saved
1731            .key_results
1732            .into_iter()
1733            .find(|k| k.id == kr_id)
1734            .expect("kr exists");
1735        assert_eq!(saved_kr.current_value, 10.0);
1736        assert_eq!(saved_kr.outcomes.len(), 1);
1737        assert!(
1738            saved_kr.outcomes[0]
1739                .evidence
1740                .iter()
1741                .any(|entry| entry == "concrete_file_evidence:false")
1742        );
1743    }
1744
1745    #[tokio::test]
1746    async fn seed_default_okr_populates_empty_repo() {
1747        let dir = tempdir().expect("create tempdir");
1748        let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1749
1750        seed_default_okr_if_empty(&repo)
1751            .await
1752            .expect("seed should succeed");
1753
1754        let okrs = repo.list_okrs().await.expect("list okrs");
1755        assert_eq!(okrs.len(), 1);
1756        assert_eq!(
1757            okrs[0].title,
1758            "Mission: Autonomous Business-Aligned Execution"
1759        );
1760        assert_eq!(okrs[0].status, OkrStatus::Active);
1761        assert_eq!(okrs[0].key_results.len(), 3);
1762    }
1763
1764    #[tokio::test]
1765    async fn seed_default_okr_is_noop_when_repo_not_empty() {
1766        let dir = tempdir().expect("create tempdir");
1767        let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1768
1769        let mut existing = Okr::new("Existing Objective", "Do not overwrite");
1770        existing.status = OkrStatus::Active;
1771        existing.add_key_result(KeyResult::new(existing.id, "KR1", 100.0, "%"));
1772        let _ = repo.create_okr(existing).await.expect("create existing");
1773
1774        seed_default_okr_if_empty(&repo)
1775            .await
1776            .expect("seed should succeed");
1777
1778        let okrs = repo.list_okrs().await.expect("list okrs");
1779        assert_eq!(okrs.len(), 1);
1780        assert_eq!(okrs[0].title, "Existing Objective");
1781    }
1782
1783    #[tokio::test]
1784    async fn moonshot_seed_creates_okr_when_no_opportunities_exist() {
1785        let dir = tempdir().expect("create tempdir");
1786        let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1787
1788        // Completed objective should not produce forage opportunities.
1789        let mut completed = Okr::new("Completed Objective", "Already done");
1790        completed.status = OkrStatus::Completed;
1791        let mut kr = KeyResult::new(completed.id, "KR done", 100.0, "%");
1792        kr.update_progress(100.0);
1793        completed.add_key_result(kr);
1794        let _ = repo.create_okr(completed).await.expect("create completed");
1795
1796        let rubric = MoonshotRubric {
1797            goals: vec![
1798                "Automate customer acquisition end-to-end".to_string(),
1799                "Funnel conversion replaces manual sales".to_string(),
1800            ],
1801            required: true,
1802            min_alignment: 0.2,
1803        };
1804
1805        let seeded_id = seed_moonshot_okr_if_no_opportunities(&repo, &rubric)
1806            .await
1807            .expect("seed should succeed");
1808        assert!(seeded_id.is_some(), "expected moonshot-derived seed OKR");
1809        let seeded_id = seeded_id.expect("seeded id");
1810
1811        let okrs = repo.list_okrs().await.expect("list okrs");
1812        let seeded = okrs
1813            .iter()
1814            .find(|o| o.id == seeded_id)
1815            .expect("seeded okr exists");
1816        assert_eq!(seeded.status, OkrStatus::Active);
1817        assert_eq!(
1818            seeded.title,
1819            "Mission: Moonshot-Derived Autonomous Execution"
1820        );
1821        assert!(!seeded.key_results.is_empty());
1822    }
1823
1824    #[tokio::test]
1825    async fn moonshot_seed_is_noop_when_open_moonshot_seed_exists() {
1826        let dir = tempdir().expect("create tempdir");
1827        let repo = crate::okr::OkrRepository::new(dir.path().to_path_buf());
1828
1829        let rubric = MoonshotRubric {
1830            goals: vec!["Tech stack is the moat".to_string()],
1831            required: true,
1832            min_alignment: 0.2,
1833        };
1834        let first = seed_moonshot_okr_if_no_opportunities(&repo, &rubric)
1835            .await
1836            .expect("first seed should succeed");
1837        assert!(first.is_some());
1838
1839        let second = seed_moonshot_okr_if_no_opportunities(&repo, &rubric)
1840            .await
1841            .expect("second seed should succeed");
1842        assert!(
1843            second.is_none(),
1844            "should not duplicate active moonshot seed"
1845        );
1846    }
1847}