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 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
955async 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 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 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 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 let task = item.prompt.clone();
997
998 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, None, 3, None, ),
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 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 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}