1use crate::contract_lint;
8use crate::cost;
9use crate::events::{Event, EventKind};
10use crate::runner;
11use crate::scrub;
12use crate::types::*;
13use std::collections::HashMap;
14use std::path::Path;
15
16#[allow(clippy::too_many_arguments)]
32pub fn render_plan_markdown(
33 plan: &Plan,
34 mission: &Mission,
35 estimate: &cost::CostEstimate,
36 two_path: Option<&cost::TwoPathEstimate>,
37 fit_note: Option<&str>,
38 missions_used: usize,
39 contract_lint: &contract_lint::ContractLintReport,
40 gate_reports: &[crate::gate::GateReport],
41 pool: &[CandidateSpec],
42) -> String {
43 use std::fmt::Write as _;
44 let mut md = String::new();
45 let _ = writeln!(md, "# Mission plan — {}", mission.id);
46 let _ = writeln!(md, "\n**Goal:** {}\n", plan.goal);
47 let _ = writeln!(
48 md,
49 "Branch `{}` (from `{}`). Approved plan of record; the machine-readable \
50 twin is [plan.json](plan.json). Live status: `kranz status` or the dashboard.\n",
51 mission.mission_branch, mission.base_branch
52 );
53
54 if let Some(policy) = plan.reviewer_independence {
55 let roles = [
56 policy.scrutiny.then_some("scrutiny"),
57 policy.functional.then_some("functional"),
58 ]
59 .into_iter()
60 .flatten()
61 .collect::<Vec<_>>()
62 .join(" and ");
63 let _ = writeln!(
64 md,
65 "## Reviewer independence\n\nRequired for {roles}: a known model \
66 family different from every recorded worker attempt. Pinned at approval; fallback, \
67 retry, unknown provenance or a skipped required reviewer cannot weaken it.\n"
68 );
69 }
70
71 let _ = writeln!(md, "## Cost estimate\n");
72 let provenance = if missions_used == 0 {
73 "built-in defaults — no completed missions yet".to_string()
74 } else {
75 format!("based on {missions_used} completed mission(s)")
76 };
77 match estimate.confidence {
78 cost::Confidence::High => {
79 let _ = writeln!(
80 md,
81 "Estimated **${:.2} – ${:.2}** (expected ~${:.2}). Rough estimate — live usage is \
82 authoritative; {provenance}.\n",
83 estimate.low_usd, estimate.high_usd, estimate.expected_usd
84 );
85 }
86 cost::Confidence::Low => {
87 let _ = writeln!(
88 md,
89 "Estimated **${:.2} – ${:.2}** (expected ~${:.2}). Doc-heavy / judgement-heavy \
90 shape — the calibration corpus lacks a comparable mission, so this is **LOW \
91 CONFIDENCE** and ${:.2} is a soft ceiling, not a tight bound; {provenance}.\n",
92 estimate.low_usd, estimate.high_usd, estimate.expected_usd, estimate.high_usd
93 );
94 }
95 }
96
97 if let Some(two_path) = two_path {
101 let _ = writeln!(
102 md,
103 "This plan routes to the **local tier**: **$0 marginal** (fixed hardware + \
104 electricity, not per-token) if it completes locally. If it escalates to frontier: \
105 **${:.2} – ${:.2}** (expected ~${:.2}), which prices the tier switch's cache-miss \
106 once (${:.2} — the first post-escalation turn re-reads the full prefix uncached; \
107 escalation happens at feature/milestone edges, where no warm cache exists to lose).\n",
108 two_path.escalated.low_usd,
109 two_path.escalated.high_usd,
110 two_path.escalated.expected_usd,
111 two_path.cache_miss_usd,
112 );
113 }
114
115 if let Some(note) = fit_note {
116 let _ = writeln!(md, "{note}\n");
117 }
118
119 if !pool.is_empty() {
128 let n = pool.len();
129 let _ = writeln!(md, "## Dispatch pool — {n} candidates per unit of work\n");
130 let _ = writeln!(
131 md,
132 "Every worker feature is dispatched to **{n} backends concurrently** \
133 (heterogeneous dispatch, KRZ-303), one git worktree per stream:"
134 );
135 for (i, candidate) in pool.iter().enumerate() {
136 let _ = writeln!(
137 md,
138 "{}. `{}` / `{}`",
139 i + 1,
140 candidate.backend,
141 candidate.model
142 );
143 }
144 let _ = writeln!(md);
145 let _ = writeln!(
146 md,
147 "Each stream's output is recorded as a sibling **candidate for judgement** tied to \
148 the same unit of work. The engine never selects, ranks, or merges a candidate into \
149 a winner — selection is a later human judgement act — and the pool's claimed value \
150 is **divergence for scrutiny, not throughput**.\n"
151 );
152 let _ = writeln!(
153 md,
154 "**Cost multiplies by {n}:** the estimate above already prices all {n} candidates \
155 for every worker unit, and the per-mission budget applies to that SUM.\n"
156 );
157 }
158
159 if let Some(alternatives) = &plan.considered_alternatives {
160 let _ = writeln!(md, "## Considered alternatives\n");
161 let _ = writeln!(md, "**Chosen approach:** {}\n", alternatives.chosen.trim());
162 if !alternatives.rejected.is_empty() {
163 let _ = writeln!(md, "Rejected shapes:");
164 for rejected in &alternatives.rejected {
165 let _ = writeln!(
166 md,
167 "- **{}** — {}",
168 rejected.approach.trim(),
169 rejected.trade_off.trim()
170 );
171 }
172 let _ = writeln!(md);
173 }
174 }
175
176 let _ = writeln!(md, "## Validation contract\n");
177 let _ = writeln!(
178 md,
179 "Defined before any feature; gates mission completion.\n"
180 );
181 for a in &plan.validation_contract {
182 match (&a.check, &a.command) {
183 (AssertionCheck::Command, Some(cmd)) => {
184 let _ = writeln!(
185 md,
186 "- **[{}]** {}\n `{}`",
187 a.id.trim(),
188 a.statement.trim(),
189 cmd.trim()
190 );
191 }
192 (AssertionCheck::PtyScript, _) => {
193 let command = a
194 .pty_script
195 .as_ref()
196 .map(|s| s.command.trim())
197 .unwrap_or("MISSING");
198 let _ = writeln!(
199 md,
200 "- **[{}]** {}\n pty script: `{command}`",
201 a.id.trim(),
202 a.statement.trim()
203 );
204 }
205 _ => {
206 let _ = writeln!(
207 md,
208 "- **[{}]** {} *(agent judgement)*",
209 a.id.trim(),
210 a.statement.trim()
211 );
212 }
213 }
214 }
215
216 if !contract_lint.is_empty() {
217 let _ = writeln!(md, "## Contract lint\n");
218 let _ = writeln!(
219 md,
220 "Each `check: command` assertion above was run once against the untouched base \
221 tree at approval time. Suspects are assertions that already pass (or could not \
222 reach a verdict) before this plan's work lands — a possible polarity/vacuity bug \
223 in the assertion itself. This never blocks approval.\n"
224 );
225 let _ = writeln!(md, "{}\n", contract_lint.summary());
226 if !gate_reports.is_empty() {
230 let _ = writeln!(
231 md,
232 "{}\n",
233 crate::contract_gates::render_gate_verdicts(gate_reports)
234 );
235 }
236 }
237
238 for (mi, m) in plan.milestones.iter().enumerate() {
239 let _ = writeln!(md, "\n## Milestone {} — {}\n", mi + 1, m.title);
240 for (fi, f) in m.features.iter().enumerate() {
241 let _ = writeln!(md, "### {}.{} {}\n", mi + 1, fi + 1, f.title);
242 let _ = writeln!(md, "{}\n", f.spec.trim());
243 if !f.validation_criteria.is_empty() {
244 let _ = writeln!(md, "Done when:");
245 for c in &f.validation_criteria {
246 let _ = writeln!(md, "- {c}");
247 }
248 let _ = writeln!(md);
249 }
250 }
251 }
252
253 if let Some(pin) = &plan.standards_manifest {
258 let _ = writeln!(md, "\n{}", crate::pack::resolution::render_pin_section(pin));
259 }
260 while md.ends_with('\n') {
261 md.pop();
262 }
263 md.push('\n');
264 md
265}
266
267#[derive(Debug, Clone, Default, serde::Deserialize)]
273#[serde(rename_all = "camelCase", default)]
274pub(crate) struct Research {
275 files_read: Vec<String>,
276 sources: Vec<String>,
277 facts: Vec<ResearchFact>,
278 ambiguities: Vec<String>,
279 candidate_knowledge_updates: Vec<String>,
280}
281
282#[derive(Debug, Clone, Default, serde::Deserialize)]
283#[serde(rename_all = "camelCase", default)]
284struct ResearchFact {
285 fact: String,
286 evidence: String,
287}
288
289impl Research {
290 fn is_empty(&self) -> bool {
291 self.files_read.is_empty()
292 && self.sources.is_empty()
293 && self.facts.is_empty()
294 && self.ambiguities.is_empty()
295 && self.candidate_knowledge_updates.is_empty()
296 }
297}
298
299pub(crate) fn extract_research(plan_text: &str) -> Option<Research> {
302 let value: serde_json::Value = runner::parse_report(plan_text)?;
303 let research: Research = serde_json::from_value(value.get("research")?.clone()).ok()?;
304 (!research.is_empty()).then_some(research)
305}
306
307pub(crate) fn render_research_markdown(research: &Research, mission_id: &str) -> String {
309 use std::fmt::Write as _;
310 let mut md = format!(
311 "# Research — {mission_id}\n\nEvidence behind the approved plan \
312 (roadmap M1 / repo-knowledge-store slice 1). Candidate knowledge updates \
313 feed `docs/knowledge/`.\n"
314 );
315 let list = |md: &mut String, title: &str, items: &[String]| {
316 let items: Vec<&str> = items
317 .iter()
318 .map(|s| s.trim())
319 .filter(|s| !s.is_empty())
320 .collect();
321 if items.is_empty() {
322 return;
323 }
324 let _ = write!(md, "\n## {title}\n\n");
325 for it in items {
326 let _ = writeln!(md, "- {it}");
327 }
328 };
329 list(&mut md, "Files & docs read", &research.files_read);
330 list(&mut md, "External sources", &research.sources);
331 let facts: Vec<&ResearchFact> = research
332 .facts
333 .iter()
334 .filter(|f| !f.fact.trim().is_empty())
335 .collect();
336 if !facts.is_empty() {
337 let _ = write!(md, "\n## Facts\n\n");
338 for f in facts {
339 let fact = f.fact.trim();
340 let ev = f.evidence.trim();
341 if ev.is_empty() {
342 let _ = writeln!(md, "- {fact}");
343 } else {
344 let _ = writeln!(md, "- {fact} — `{ev}`");
345 }
346 }
347 }
348 list(&mut md, "Ambiguities & stale docs", &research.ambiguities);
349 list(
350 &mut md,
351 "Candidate knowledge updates",
352 &research.candidate_knowledge_updates,
353 );
354 md
355}
356
357pub fn render_revised_plan_markdown(
364 plan: &Plan,
365 mission: &Mission,
366 dropped_feature_ids: &[String],
367 added_features: &[PlanFeature],
368) -> String {
369 use std::fmt::Write as _;
370 let mut md = String::new();
371 let _ = writeln!(md, "# Revised mission plan — {}", mission.id);
372 let _ = writeln!(md, "\n**Goal:** {}\n", plan.goal);
373 let _ = writeln!(
374 md,
375 "Branch `{}` (from `{}`). Mid-mission revision of the plan of record \
376 ([plan.md](plan.md)); completed milestones are preserved unchanged.\n",
377 mission.mission_branch, mission.base_branch
378 );
379
380 let _ = writeln!(md, "## Re-plan changes applied\n");
381 let _ = writeln!(
382 md,
383 "Completed milestones are frozen unchanged. Remaining milestones are merged by \
384 position; existing pending features match by title, omitted pending features are \
385 skipped, and new feature titles are appended with revision-scoped ids. Review the \
386 resulting plan below and the `plan.revised` event for the exact machine state.\n"
387 );
388 if !dropped_feature_ids.is_empty() {
389 let _ = writeln!(
390 md,
391 "- Dropped (skipped) features: {}",
392 dropped_feature_ids.join(", ")
393 );
394 }
395 if !added_features.is_empty() {
396 let titles: Vec<String> = added_features
397 .iter()
398 .map(|f| f.title.trim().to_string())
399 .collect();
400 let _ = writeln!(md, "- Added features: {}", titles.join(", "));
401 }
402 if dropped_feature_ids.is_empty() && added_features.is_empty() {
403 let _ = writeln!(
404 md,
405 "- Per-feature legacy diff: not supplied for this revision path"
406 );
407 }
408 let _ = writeln!(md);
409
410 let _ = writeln!(md, "## Full revised plan\n");
411 for (mi, m) in plan.milestones.iter().enumerate() {
412 let _ = writeln!(md, "### Milestone {} — {}\n", mi + 1, m.title);
413 for (fi, f) in m.features.iter().enumerate() {
414 let _ = writeln!(md, "#### {}.{} {}\n", mi + 1, fi + 1, f.title);
415 let _ = writeln!(md, "{}\n", f.spec.trim());
416 if !f.validation_criteria.is_empty() {
417 let _ = writeln!(md, "Done when:");
418 for c in &f.validation_criteria {
419 let _ = writeln!(md, "- {c}");
420 }
421 let _ = writeln!(md);
422 }
423 }
424 }
425 while md.ends_with('\n') {
426 md.pop();
427 }
428 md.push('\n');
429 md
430}
431
432fn latest_decision_summary<'a>(events: &'a [Event], prefix: &str) -> Option<&'a str> {
437 events.iter().rev().find_map(|event| match &event.kind {
438 EventKind::OrchestratorDecision { summary, .. } => {
439 summary.strip_prefix(prefix).map(str::trim)
440 }
441 _ => None,
442 })
443}
444
445pub fn render_mission_report(
457 state: &MissionState,
458 events: &[Event],
459 plan: &Plan,
460 estimate: &cost::CostEstimate,
461 execution_root: &Path,
462 workspace_contract: Option<&crate::workspace_contract::WorkspaceContract>,
463) -> String {
464 use std::fmt::Write as _;
465 let mission = &state.mission;
466 let mut md = String::new();
467 let _ = writeln!(md, "# Mission report — {}", mission.id);
468 let _ = writeln!(md, "\n**Goal:** {}\n", mission.goal);
469 let _ = writeln!(
470 md,
471 "Branch `{}` (from `{}`). Plan of record: [plan.md](plan.md).\n",
472 mission.mission_branch, mission.base_branch
473 );
474
475 let _ = writeln!(md, "## The plan\n");
478 let feature_total: usize = mission.milestones.iter().map(|m| m.features.len()).sum();
479 let command_assertions = plan
480 .validation_contract
481 .iter()
482 .filter(|a| a.check == AssertionCheck::Command)
483 .count();
484 let judgement_assertions = plan.validation_contract.len() - command_assertions;
485 let _ = writeln!(
486 md,
487 "{} milestone{}, {} feature{}, gated by {} contract assertion{} ({} command, {} judgement).\n",
488 mission.milestones.len(),
489 if mission.milestones.len() == 1 { "" } else { "s" },
490 feature_total,
491 if feature_total == 1 { "" } else { "s" },
492 plan.validation_contract.len(),
493 if plan.validation_contract.len() == 1 { "" } else { "s" },
494 command_assertions,
495 judgement_assertions,
496 );
497 for (mi, m) in plan.milestones.iter().enumerate() {
498 let _ = writeln!(md, "{}. **{}**", mi + 1, m.title);
499 for f in &m.features {
500 let intent = first_sentence(&f.spec);
501 if intent.is_empty() {
502 let _ = writeln!(md, " - {}", f.title);
503 } else {
504 let _ = writeln!(md, " - {} — {intent}", f.title);
505 }
506 }
507 }
508 if let Some(alternatives) = &plan.considered_alternatives {
509 let chosen = first_sentence(&alternatives.chosen);
510 if !chosen.is_empty() {
511 let _ = writeln!(md, "\n**Chosen approach:** {chosen}");
512 }
513 }
514
515 let completed_ts = events
517 .iter()
518 .rev()
519 .find_map(|e| matches!(e.kind, EventKind::MissionCompleted {}).then_some(e.ts))
520 .or_else(|| events.last().map(|e| e.ts))
521 .unwrap_or(mission.created_at);
522 let paused = paused_time(events, completed_ts);
523 let elapsed = std::cmp::max(
524 completed_ts - mission.created_at - paused,
525 chrono::Duration::zero(),
526 );
527 let _ = write!(md, "**Elapsed:** {}", format_duration(elapsed));
528 if paused > chrono::Duration::zero() {
529 let _ = write!(md, " ({} paused)", format_duration(paused));
530 }
531 let _ = writeln!(md);
532 let t = &state.totals;
533 let _ = writeln!(
534 md,
535 "**Tokens:** {} in / {} out / {} cache read / {} cache write",
536 t.input, t.output, t.cache_read, t.cache_write
537 );
538 let cost_note = match crate::cost::mission_cost_class(state) {
541 crate::cost::MissionCostClass::Frontier => "",
542 crate::cost::MissionCostClass::Local => {
543 " — local tier: $0 marginal (fixed hardware + electricity, not \
544 per-token); excluded from frontier-cost calibration"
545 }
546 crate::cost::MissionCostClass::Mixed => {
547 " — mixed local→frontier (escalated mid-mission); excluded from \
548 frontier-cost calibration"
549 }
550 };
551 let _ = writeln!(
552 md,
553 "**Cost:** ${:.2} actual{cost_note} vs ${:.2}–${:.2} estimated (expected ${:.2})",
554 state.total_cost_usd, estimate.low_usd, estimate.high_usd, estimate.expected_usd
555 );
556
557 let _ = writeln!(md, "\n## Workspace");
558 let isolation = match state.config.isolation() {
559 WorkerIsolation::Worktree => "worktree",
560 WorkerIsolation::Checkout => "checkout",
561 };
562 let _ = writeln!(md, "- **Isolation:** `{isolation}`");
563 let _ = writeln!(
564 md,
565 "- **Worker/validator cwd:** `{}`",
566 execution_root.display()
567 );
568 let _ = writeln!(
569 md,
570 "- **Sandbox:** worker `{}`; scrutiny `{}`; functional `{}`",
571 sandbox_enforce_label(state.config.worker.sandbox.enforce),
572 sandbox_enforce_label(state.config.validator_scrutiny.sandbox.enforce),
573 sandbox_enforce_label(state.config.validator_functional.sandbox.enforce),
574 );
575 if let Some(pin) = &state.workspace_pin {
582 let provider_note = if pin.provider == "local-worktree" {
583 " (source isolation)"
584 } else {
585 ""
586 };
587 let version_label = if pin.provider == "remote" {
588 format!("adapter {}", pin.version)
591 } else if pin.version == "none" {
592 "no workspace contract".to_string()
593 } else {
594 format!("contract schema v{}", pin.version)
595 };
596 let _ = writeln!(
597 md,
598 "- **Provider:** {}{provider_note} · template: {} · {version_label}",
599 pin.provider, pin.template
600 );
601 }
602 match workspace_contract {
607 Some(contract) => {
608 let _ = writeln!(
609 md,
610 "- **Workspace contract:** present ({} services, {} previews)",
611 contract.services.len(),
612 contract.previews.len()
613 );
614 for (label, prefix) in [
615 ("Bootstrap", crate::workspace_gate::BOOTSTRAP_SUMMARY_PREFIX),
616 ("Readiness", crate::workspace_gate::READINESS_SUMMARY_PREFIX),
617 ] {
618 match latest_decision_summary(events, prefix) {
619 Some(outcome) => {
620 let _ = writeln!(md, "- **{label}:** {outcome}");
621 }
622 None => {
623 let _ = writeln!(md, "- **{label}:** not run yet");
624 }
625 }
626 }
627 }
628 None => {
629 let _ = writeln!(
630 md,
631 "- **Workspace contract:** no workspace contract (source isolation only)"
632 );
633 }
634 }
635 let preflight = events.iter().rev().find_map(|event| match &event.kind {
636 EventKind::OrchestratorDecision { summary, .. } if summary.starts_with("preflight:") => {
637 Some(summary.as_str())
638 }
639 _ => None,
640 });
641 match preflight {
642 Some(summary) => {
643 let _ = writeln!(md, "- **Preflight:** {summary}");
644 }
645 None => {
646 let _ = writeln!(md, "- **Preflight:** clear — no advisory issues recorded");
647 }
648 }
649
650 let _ = writeln!(md, "\n## What shipped");
653 for (mi, m) in mission.milestones.iter().enumerate() {
654 let _ = writeln!(
655 md,
656 "\n### Milestone {} — {} {}\n",
657 mi + 1,
658 m.title,
659 milestone_icon(m.status)
660 );
661 for f in &m.features {
662 let runs = f.worker_runs.len();
663 let _ = write!(
664 md,
665 "- {} **{}**{} — {} run{}",
666 feature_icon(f.status),
667 f.title,
668 if f.origin == FeatureOrigin::Fix {
669 " *(fix)*"
670 } else {
671 ""
672 },
673 runs,
674 if runs == 1 { "" } else { "s" },
675 );
676 if f.respawns > 0 {
677 let _ = write!(
678 md,
679 ", {} respawn{}",
680 f.respawns,
681 if f.respawns == 1 { "" } else { "s" }
682 );
683 }
684 let _ = writeln!(md);
685 let intent = first_sentence(&f.spec);
687 if !intent.is_empty() {
688 let _ = writeln!(md, " {intent}");
689 }
690 for commit in &f.commits {
691 let _ = writeln!(md, " - {}", short_commit(commit));
692 }
693 let mut candidates: Vec<&WorkerRun> = f
699 .worker_runs
700 .iter()
701 .filter_map(|id| state.runs.get(id))
702 .filter(|r| r.candidate.is_some())
703 .collect();
704 if !candidates.is_empty() {
705 candidates.sort_by_key(|r| r.candidate.as_ref().map(|c| c.index).unwrap_or(0));
706 let n = candidates
707 .first()
708 .and_then(|r| r.candidate.as_ref())
709 .map(|c| c.count)
710 .unwrap_or(candidates.len() as u32);
711 let _ = writeln!(
712 md,
713 " **{} candidates for judgement** (no winner selected; selection is a \
714 later human judgement act):",
715 candidates.len()
716 );
717 for r in candidates {
718 let c = r
719 .candidate
720 .as_ref()
721 .expect("filtered to candidate-linked runs");
722 let result = match r.result {
723 Some(RunResult::Pass) => "pass",
724 Some(RunResult::Fail) => "fail",
725 Some(RunResult::Partial) => "partial",
726 None => "no terminal state recorded",
727 };
728 let _ = writeln!(
729 md,
730 " - candidate {}/{}: `{}` / `{}` — {} — branch `kranz/pool/{}/{}-c{}`",
731 c.index,
732 n.saturating_sub(1),
733 c.backend,
734 r.model,
735 result,
736 mission.id,
737 f.id,
738 c.index
739 );
740 }
741 }
742 if f.status == FeatureStatus::Complete {
743 for criterion in &f.validation_criteria {
744 let _ = writeln!(md, " - ✓ {criterion}");
745 }
746 }
747 }
748 }
749
750 let _ = writeln!(md, "\n## Validation history");
752 let rounds = collect_validation_rounds(events);
753 let mut per_milestone_round: HashMap<&str, usize> = HashMap::new();
754 let mut rendered_any = false;
755 for round in &rounds {
756 if round.findings.is_empty() && !round.clean {
760 continue;
761 }
762 rendered_any = true;
763 match round.milestone_id {
764 Some(id) => {
765 let n = per_milestone_round.entry(id).or_insert(0);
766 *n += 1;
767 let title = mission
768 .milestones
769 .iter()
770 .find(|m| m.id == id)
771 .map(|m| m.title.as_str())
772 .unwrap_or("");
773 let _ = writeln!(md, "\n### {id} round {n} — {title}\n");
774 }
775 None => {
776 let _ = writeln!(md, "\n### Final gate\n");
777 }
778 }
779 if round.findings.is_empty() {
780 let _ = writeln!(md, "No findings.");
781 continue;
782 }
783 for (run_id, finding) in &round.findings {
784 let gate = if *run_id == crate::reducer::ENGINE_RUN_ID {
785 " *(final gate)*"
786 } else {
787 ""
788 };
789 let evidence = scrub::scrub_and_truncate(
790 &finding
791 .evidence
792 .split_whitespace()
793 .collect::<Vec<_>>()
794 .join(" "),
795 200,
796 );
797 let _ = writeln!(
798 md,
799 "- [{}] {}{gate} — {evidence}",
800 finding.severity, finding.subject
801 );
802 }
803 if round.fix_features > 0 {
804 let _ = writeln!(
805 md,
806 "\nDisposition: {} fix feature(s) created.",
807 round.fix_features
808 );
809 }
810 if !round.waived.is_empty() {
811 let _ = writeln!(md, "\nDisposition: waived.");
812 for reasons in &round.waived {
813 for line in reasons.lines() {
814 let _ = writeln!(md, "{line}");
815 }
816 }
817 }
818 if let Some(reason) = round.blocked {
819 let _ = writeln!(md, "\nDisposition: milestone blocked — {reason}");
820 }
821 }
822 if !rendered_any {
823 let _ = writeln!(md, "\nNo validation rounds were recorded.");
824 }
825
826 if let Some(coverage) = crate::standards_coverage::standards_coverage(&mission.id, events) {
832 let _ = writeln!(
833 md,
834 "\n{}",
835 crate::standards_coverage::render_coverage_markdown(&coverage).trim_end_matches('\n')
836 );
837 }
838
839 let _ = writeln!(md, "\n## Contract outcomes");
842 if plan.validation_contract.is_empty() {
843 let _ = writeln!(md, "\nNo contract assertions were defined.");
844 } else {
845 let _ = writeln!(md);
846 for a in &plan.validation_contract {
847 let check = match (&a.check, &a.command) {
848 (AssertionCheck::Command, Some(cmd)) => format!("command: `{cmd}`"),
849 (AssertionCheck::Command, None) => "command".to_string(),
850 (AssertionCheck::PtyScript, _) => "pty script".to_string(),
851 _ => "agent judgement".to_string(),
852 };
853 let _ = writeln!(md, "- ✅ **[{}]** {} *({check})*", a.id, a.statement);
854 }
855 let _ = writeln!(
856 md,
857 "\nAll assertions passed at the final contract gate (waivers, if any, appear in \
858 the validation history)."
859 );
860 }
861 md
862}
863
864struct ValidationRound<'a> {
868 milestone_id: Option<&'a str>,
870 findings: Vec<(&'a str, &'a Finding)>,
872 fix_features: usize,
873 waived: Vec<&'a str>,
876 blocked: Option<&'a str>,
877 clean: bool,
879}
880
881fn collect_validation_rounds(events: &[Event]) -> Vec<ValidationRound<'_>> {
887 fn round(milestone_id: Option<&str>) -> ValidationRound<'_> {
888 ValidationRound {
889 milestone_id,
890 findings: Vec::new(),
891 fix_features: 0,
892 waived: Vec::new(),
893 blocked: None,
894 clean: false,
895 }
896 }
897 let mut rounds: Vec<ValidationRound<'_>> = Vec::new();
898 for event in events {
899 match &event.kind {
900 EventKind::MilestoneValidating { milestone_id } => {
901 rounds.push(round(Some(milestone_id)));
902 }
903 EventKind::MissionValidating {} => rounds.push(round(None)),
904 EventKind::ValidationFinding {
905 milestone_id,
906 run_id,
907 finding,
908 } => {
909 let gate = run_id == crate::reducer::ENGINE_RUN_ID;
913 let fits = rounds
914 .last()
915 .is_some_and(|r| !gate || r.milestone_id.is_none());
916 if !fits {
917 rounds.push(round(if gate { None } else { Some(milestone_id) }));
918 }
919 rounds
920 .last_mut()
921 .expect("pushed above")
922 .findings
923 .push((run_id, finding));
924 }
925 EventKind::FixFeatureCreated { .. } => {
926 if let Some(r) = rounds.iter_mut().rev().find(|r| !r.findings.is_empty()) {
927 r.fix_features += 1;
928 }
929 }
930 EventKind::OrchestratorDecision { summary, detail }
931 if summary.starts_with("waived") =>
932 {
933 if let Some(r) = rounds.iter_mut().rev().find(|r| !r.findings.is_empty()) {
934 r.waived.push(detail.as_deref().unwrap_or(summary));
935 }
936 }
937 EventKind::MilestoneBlocked { reason, .. } => {
938 if let Some(r) = rounds.iter_mut().rev().find(|r| !r.findings.is_empty()) {
939 r.blocked = Some(reason);
940 }
941 }
942 EventKind::MilestoneCompleted { milestone_id, .. } => {
943 if let Some(r) = rounds.last_mut() {
944 if r.milestone_id == Some(milestone_id.as_str()) && r.findings.is_empty() {
945 r.clean = true;
946 }
947 }
948 }
949 _ => {}
950 }
951 }
952 rounds
953}
954
955fn paused_time(events: &[Event], end: chrono::DateTime<chrono::Utc>) -> chrono::Duration {
959 let mut total = chrono::Duration::zero();
960 let mut paused_at: Option<chrono::DateTime<chrono::Utc>> = None;
961 for event in events {
962 match &event.kind {
963 EventKind::MissionPaused {} => {
964 if paused_at.is_none() {
965 paused_at = Some(event.ts);
966 }
967 }
968 EventKind::MissionResumed {} => {
969 if let Some(start) = paused_at.take() {
970 total += event.ts - start;
971 }
972 }
973 _ => {}
974 }
975 }
976 if let Some(start) = paused_at {
977 total += end - start;
978 }
979 total
980}
981
982fn format_duration(d: chrono::Duration) -> String {
984 let secs = d.num_seconds().max(0);
985 let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
986 if h > 0 {
987 format!("{h}h {m:02}m {s:02}s")
988 } else if m > 0 {
989 format!("{m}m {s:02}s")
990 } else {
991 format!("{s}s")
992 }
993}
994
995fn short_commit(entry: &str) -> String {
999 let (sha, subject) = match entry.split_once(' ') {
1000 Some((sha, subject)) => (sha, subject.trim()),
1001 None => (entry, ""),
1002 };
1003 let looks_sha = sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit());
1004 match (looks_sha, subject.is_empty()) {
1005 (true, false) => format!("`{}` {}", &sha[..7], subject),
1006 (true, true) => format!("`{}`", &sha[..7]),
1007 _ => entry.to_string(),
1008 }
1009}
1010
1011fn first_sentence(text: &str) -> String {
1015 const MAX: usize = 140;
1016 let trimmed = text.trim();
1017 if trimmed.is_empty() {
1018 return String::new();
1019 }
1020 let first_line = trimmed.lines().next().unwrap_or("").trim();
1021 let sentence_end = first_line
1022 .find(". ")
1023 .map(|i| i + 1)
1024 .unwrap_or(first_line.len());
1025 let candidate = first_line[..sentence_end].trim();
1026 let candidate = if candidate.is_empty() {
1027 first_line
1028 } else {
1029 candidate
1030 };
1031 if candidate.chars().count() <= MAX {
1032 candidate.to_string()
1033 } else {
1034 let mut out: String = candidate.chars().take(MAX - 1).collect();
1035 out.push('…');
1036 out
1037 }
1038}
1039
1040fn feature_icon(status: FeatureStatus) -> &'static str {
1041 match status {
1042 FeatureStatus::Complete => "✅",
1043 FeatureStatus::Failed => "❌",
1044 FeatureStatus::Skipped => "⏭",
1045 FeatureStatus::Active | FeatureStatus::Pending => "⏳",
1046 }
1047}
1048
1049fn milestone_icon(status: MilestoneStatus) -> &'static str {
1050 match status {
1051 MilestoneStatus::Complete => "✅",
1052 MilestoneStatus::Blocked => "⛔",
1053 _ => "⏳",
1054 }
1055}
1056
1057fn sandbox_enforce_label(enforce: SandboxEnforce) -> &'static str {
1058 match enforce {
1059 SandboxEnforce::Off => "off",
1060 SandboxEnforce::Fs => "fs",
1061 SandboxEnforce::FsNet => "fs+net",
1062 }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067 use super::*;
1068
1069 #[test]
1070 fn extract_research_pulls_the_optional_object() {
1071 let text = r#"{
1072 "goal": "g",
1073 "milestones": [{"title":"m","features":[{"title":"f","spec":"s","validationCriteria":["c"]}]}],
1074 "validationContract": [],
1075 "research": {
1076 "filesRead": ["crates/engine/src/orchestrator.rs"],
1077 "sources": ["https://example.com"],
1078 "facts": [{"fact":"the run loop folds events","evidence":"reducer.rs"}],
1079 "ambiguities": ["stale doc X"],
1080 "candidateKnowledgeUpdates": ["add architecture/run-loop.md"]
1081 }
1082 }"#;
1083 let r = extract_research(text).expect("research present");
1084 assert_eq!(r.files_read, vec!["crates/engine/src/orchestrator.rs"]);
1085 assert_eq!(r.facts.len(), 1);
1086 assert_eq!(r.facts[0].evidence, "reducer.rs");
1087 assert_eq!(r.candidate_knowledge_updates.len(), 1);
1088
1089 let none = r#"{"goal":"g","milestones":[{"title":"m","features":[{"title":"f","spec":"s","validationCriteria":["c"]}]}],"validationContract":[]}"#;
1091 assert!(extract_research(none).is_none());
1092 let empty = r#"{"goal":"g","milestones":[],"validationContract":[],"research":{}}"#;
1094 assert!(extract_research(empty).is_none());
1095 }
1096
1097 #[test]
1098 fn render_research_markdown_lays_out_sections() {
1099 let r = Research {
1100 files_read: vec!["a.rs".into(), " ".into()],
1101 sources: vec![],
1102 facts: vec![
1103 ResearchFact {
1104 fact: "x holds".into(),
1105 evidence: "a.rs:10".into(),
1106 },
1107 ResearchFact {
1108 fact: " ".into(),
1109 evidence: "".into(),
1110 },
1111 ],
1112 ambiguities: vec!["doc drift".into()],
1113 candidate_knowledge_updates: vec!["note Y".into()],
1114 };
1115 let md = render_research_markdown(&r, "m-1");
1116 assert!(md.contains("# Research — m-1"), "{md}");
1117 assert!(md.contains("## Files & docs read"));
1118 assert!(md.contains("- a.rs"));
1119 assert!(md.contains("## Facts"));
1120 assert!(md.contains("- x holds — `a.rs:10`"), "{md}");
1121 assert!(md.contains("## Ambiguities & stale docs"));
1122 assert!(md.contains("- note Y"));
1123 assert!(!md.contains("## External sources"));
1125 assert!(!md.contains("- \n"));
1126 }
1127}