Skip to main content

kranz_engine/
report_render.rs

1//! Markdown renderers for the mission artifacts — `plan.md`, `research.md`,
2//! `revised-plan.md`, and `report.md` — extracted from `orchestrator.rs` in
3//! the monolith split (pure code motion, no behavior change). Everything here
4//! is pure and deterministic given its inputs (no wall-clock reads): the
5//! orchestrator computes estimates and timestamps and passes them in.
6
7use 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/// Render the approved plan as human-readable markdown — committed to the
17/// mission branch beside plan.json for later review and reference. Pure and
18/// deterministic given its inputs (no wall-clock reads; the estimate is
19/// computed by the caller and passed in).
20///
21/// `gate_reports` carries the named contract-validation gate verdicts
22/// ([`crate::contract_gates`], ticket contract-validation-gates.md) rendered
23/// into the Contract lint section; callers that do not run the gates
24/// (re-plan previews — see the orchestrator's no-lint note) pass `&[]`.
25///
26/// `pool` is the mission's configured dispatch pool
27/// ([`crate::types::MissionConfig::worker_candidates`], KRZ-303): empty for
28/// the ordinary single-backend shape (no pool section rendered); the
29/// approval-time spend-consent surface otherwise — it must name N and say
30/// what the multiplied estimate pays for.
31#[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    // Local-tier routing shows both paths (cache-miss-tier-switch-pricing):
98    // a single number is wrong in both directions when a mission can
99    // complete at $0 marginal or escalate to frontier.
100    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    // Dispatch-pool spend consent (KRZ-303; the positioning ADR's 2026-07-31
120    // boundary gloss). The approval surface must name N and say what the
121    // multiplied estimate pays for — the operator is consenting to N paid
122    // worker sessions per unit of work, and the per-mission budget applies
123    // to the SUM. The two negative claims are the ticket's other two freeze
124    // properties, stated where consent is given: outputs are candidates for
125    // judgement (never auto-merged into a winner), and the claimed value is
126    // divergence for scrutiny (never throughput).
127    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        // Named contract-validation gates (contract-validation-gates): the
227        // defect classes behind the lint, each verdict carrying its class
228        // name. Same advisory posture as the lint.
229        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    // Flight Rules (KRZ-342, D-G's plan-review projection): the approved
254    // standards pin — source digest, ids, revisions, effective statuses,
255    // statements, scopes, checker bindings — rendered where the operator
256    // reviews the plan. Absent pin ⇒ byte-identical plan.md.
257    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/// The `research` evidence object the orchestrator may emit alongside the plan
268/// JSON (repo-knowledge-store slice 1). The [`Plan`] struct ignores it (no
269/// `deny_unknown_fields`); [`extract_research`] pulls it from the raw JSON so it
270/// can be rendered to `research.md` without adding a field to every `Plan`
271/// literal. All fields optional so a partial object still parses.
272#[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
299/// Pull the optional `research` object out of the orchestrator's plan JSON.
300/// Returns `None` when absent, unparseable, or empty.
301pub(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
307/// Render a `research.md` audit artifact from the extracted evidence.
308pub(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
357/// Render the revised plan as human-readable markdown for review (roadmap M2),
358/// committed to the mission branch as `revised-plan.md`. Shows the full revised
359/// plan plus a "Re-plan changes" section describing the reducer's merge rules.
360/// Older single-milestone re-plan callers can still pass explicit dropped/added
361/// feature lists; the event-driven M2 path records the exact structural change
362/// in the `plan.revised` event and folded state.
363pub 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
432/// Latest `orchestrator.decision` summary carrying `prefix`, with the prefix
433/// stripped (the workspace gate's bootstrap/readiness outcome lines). The
434/// LATEST matching decision wins — a later run's outcome supersedes an
435/// earlier one, mirroring the preflight line below.
436fn 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
445/// Render the mission completion report (roadmap M1): elapsed time and cost
446/// vs the pre-mission estimate, what shipped per feature, the validation
447/// history including waived findings with their justifications, and the
448/// contract outcomes. Committed beside plan.md at mission completion and
449/// linked from missions/index.md.
450///
451/// Deterministic given its inputs: every timestamp derives from `events`
452/// (no wall clock — the completion instant is the mission.completed ts when
453/// present, else the last event's, since the engine renders the report just
454/// before emitting mission.completed). No scrubbing happens here: all
455/// model-authored text in `state`/`events` was scrubbed at emit time.
456pub 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    // Background before details (the explain-diff shape): what the mission
476    // intended, in the plan's own words, before any numbers.
477    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    // Elapsed wall clock: created → completed, minus paused spans.
516    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    // Distinguish local/mixed spend from paid frontier spend rather than
539    // reporting a misleading bare $0 (local-inference-cost-accounting).
540    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    // D-B/D-E consent pin: the provider identity the operator approved,
576    // folded from `workspace.provider.pinned`. Omitted on missions approved
577    // before pinning existed (never fabricated). For local-worktree the pin
578    // says "source isolation" out loud — never implying a runnable workspace
579    // (D-H); the no-contract variant says "no workspace contract" rather than
580    // inventing a schema version.
581    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            // The remote kind pins the ADAPTER version, not a contract
589            // schema (ticket workspace-remote-coder-provider).
590            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    // D-H operator language: say when only source isolation is active versus
603    // a workspace contract being present — and, when present, how the
604    // bootstrap/readiness gate went (derived from the gate's decision
605    // events; workspace_gate owns the summary shapes).
606    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    // What shipped — per milestone, per feature (fix features included, in
651    // the order the reducer materialized them).
652    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            // The explain-diff walkthrough: intent before evidence, inline.
686            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            // Dispatch-pool candidates (KRZ-303): the unit's sibling outputs,
694            // labelled candidates-for-judgement — the engine never selected,
695            // ranked, or merged a winner; selection is the pending human
696            // judgement act. Each candidate names its backend, its recorded
697            // terminal state, and its (kept) branch so a judge can diff it.
698            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    // Validation history — replayed from the event log.
751    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        // Rounds with neither findings nor a clean completion are reopening
757        // bookkeeping (the gate's fix path re-emits milestone.validating
758        // before fixfeature.created), not validation rounds.
759        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    // Flight Rules coverage (KRZ-343, design D-H): every applicable pinned
827    // rule's disposition with its mechanism and evidence joins, folded from
828    // the same log — absence of evidence is never rendered as pass. `None`
829    // (no approved standards pin) renders nothing, so a pre-Flight-Rules
830    // mission's report stays byte-identical.
831    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    // Contract outcomes — the mission completed, so every assertion passed
840    // the final gate (or was explicitly waived; waivers are recorded above).
841    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
864/// One validation round replayed from the event log: a `milestone.validating`
865/// round, or the final contract gate (`mission.validating` / engine-attributed
866/// findings).
867struct ValidationRound<'a> {
868    /// `None` marks the final gate.
869    milestone_id: Option<&'a str>,
870    /// `(run_id, finding)` in emit order.
871    findings: Vec<(&'a str, &'a Finding)>,
872    fix_features: usize,
873    /// Waiver reason blocks: the detail of "waived …" orchestrator decisions
874    /// (one `- subject: reason` line per finding), falling back to the summary.
875    waived: Vec<&'a str>,
876    blocked: Option<&'a str>,
877    /// The round produced no findings and the milestone completed directly.
878    clean: bool,
879}
880
881/// Group the log's validation traffic into [`ValidationRound`]s. Dispositions
882/// (fix features, waivers, blocks) always follow the findings they answer, so
883/// they attach to the last round that has findings — which also covers the
884/// gate's fix path, where the reopening `milestone.validating` arrives between
885/// the gate findings and their `fixfeature.created` events.
886fn 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                // Engine-attributed findings belong to a gate round; the
910                // second gate pass runs without a fresh mission.validating
911                // (the status is already Validating), so open one on demand.
912                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
955/// Total time the mission spent paused: fold `mission.paused`/`mission.resumed`
956/// spans; a pause still open at `end` counts up to `end` (defensive — a
957/// completed mission always resumed).
958fn 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
982/// `4h 02m 09s` / `4m 02s` / `42s` (whole seconds; sub-second missions say 0s).
983fn 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
995/// One `feature.completed` commit entry (`<sha> <subject>`) as markdown:
996/// short sha in backticks + the subject. Entries that don't look like a sha
997/// pass through verbatim.
998fn 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
1011/// The first sentence (or first line, whichever is shorter) of a spec/prose
1012/// block, trimmed and capped — the explain-diff "intent" line for a feature
1013/// or approach. Returns "" for empty input so callers can omit the line.
1014fn 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        // Absent research -> None.
1090        let none = r#"{"goal":"g","milestones":[{"title":"m","features":[{"title":"f","spec":"s","validationCriteria":["c"]}]}],"validationContract":[]}"#;
1091        assert!(extract_research(none).is_none());
1092        // Empty research object -> None (nothing to render).
1093        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        // Empty sources section is omitted; the blank fact is skipped.
1124        assert!(!md.contains("## External sources"));
1125        assert!(!md.contains("-  \n"));
1126    }
1127}