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 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 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 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 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 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) }
785 };
786
787 Ok(ExecutionOutcome {
788 detail: final_detail,
789 changed_files,
790 quality_gates_passed: quality_passed,
791 })
792}
793
794async fn run_quality_gates(changed_files: &[String]) -> Result<(String, bool)> {
796 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 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 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
954async 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 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 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 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 let task = item.prompt.clone();
996
997 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, None, 3, None, ),
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 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 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}