Skip to main content

plan_issue/
lifecycle_record.rs

1use std::collections::BTreeMap;
2
3use nils_markdown::Engine;
4use serde::Serialize;
5use serde_json::Value;
6
7use crate::commands::record::{LifecycleCommentKind, RecordProfile, TaskLedgerDisplay};
8
9const DASHBOARD_TEMPLATE: &str = include_str!("../templates/lifecycle_record/dashboard.md.tera");
10const DASHBOARD_TEMPLATE_NAME: &str = "lifecycle_record_dashboard";
11
12const SNAPSHOT_TEMPLATE: &str = include_str!("../templates/lifecycle_record/snapshot.md.tera");
13const SNAPSHOT_TEMPLATE_NAME: &str = "lifecycle_record_snapshot";
14
15const POST_COMMENT_TEMPLATE: &str =
16    include_str!("../templates/lifecycle_record/post_comment.md.tera");
17const POST_COMMENT_TEMPLATE_NAME: &str = "lifecycle_record_post_comment";
18
19const STATE_VISIBLE_TEMPLATE: &str = include_str!("../templates/lifecycle_record/state.md.tera");
20const STATE_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_state";
21
22const SESSION_VISIBLE_TEMPLATE: &str =
23    include_str!("../templates/lifecycle_record/session.md.tera");
24const SESSION_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_session";
25
26const VALIDATION_VISIBLE_TEMPLATE: &str =
27    include_str!("../templates/lifecycle_record/validation.md.tera");
28const VALIDATION_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_validation";
29
30const REVIEW_VISIBLE_TEMPLATE: &str = include_str!("../templates/lifecycle_record/review.md.tera");
31const REVIEW_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_review";
32
33const CLOSEOUT_VISIBLE_TEMPLATE: &str =
34    include_str!("../templates/lifecycle_record/closeout.md.tera");
35const CLOSEOUT_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_closeout";
36
37#[derive(Debug, Serialize)]
38struct PostCommentView<'a> {
39    marker: String,
40    heading: &'static str,
41    profile: &'a str,
42    visible_content: String,
43    envelope_carrier: String,
44}
45
46#[derive(Debug, Serialize)]
47struct StateVisibleView<'a> {
48    status: Option<&'static str>,
49    target_scope: Option<&'a str>,
50    current: Option<&'a str>,
51    next_action: Option<&'a str>,
52    tasks: Vec<StateTaskRow>,
53}
54
55#[derive(Debug, Serialize)]
56struct StateTaskRow {
57    id: String,
58    status: &'static str,
59    title: String,
60}
61
62#[derive(Debug, Serialize)]
63struct SessionVisibleView<'a> {
64    summary: &'a str,
65    highlights: Vec<String>,
66    links: Vec<KeyValuePair>,
67    extras: Vec<KeyValuePair>,
68}
69
70#[derive(Debug, Serialize)]
71struct KeyValuePair {
72    key: String,
73    value: String,
74}
75
76#[derive(Debug, Serialize)]
77struct ValidationVisibleView<'a> {
78    overall: &'static str,
79    commands: Vec<ValidationCommandRow<'a>>,
80    waivers: Vec<ValidationWaiverRow<'a>>,
81}
82
83#[derive(Debug, Serialize)]
84struct ValidationCommandRow<'a> {
85    command: String,
86    status: &'static str,
87    evidence: String,
88    _phantom: std::marker::PhantomData<&'a ()>,
89}
90
91#[derive(Debug, Serialize)]
92struct ValidationWaiverRow<'a> {
93    command: &'a str,
94    reason: &'a str,
95}
96
97#[derive(Debug, Serialize)]
98struct ReviewVisibleView<'a> {
99    decision: &'static str,
100    lenses: Option<String>,
101    outcome_comment_url: Option<&'a str>,
102    findings: Vec<ReviewFindingRow>,
103}
104
105#[derive(Debug, Serialize)]
106struct ReviewFindingRow {
107    id: String,
108    severity: &'static str,
109    disposition: &'static str,
110    summary: String,
111}
112
113#[derive(Debug, Serialize)]
114struct CloseoutVisibleView<'a> {
115    final_status: &'a str,
116    approver: Option<&'a str>,
117    approval_url: Option<&'a str>,
118    final_validation_url: Option<&'a str>,
119    notes: Option<&'a str>,
120    has_override: bool,
121    override_reason: Option<String>,
122    override_failures: Option<String>,
123    linked_prs: Vec<CloseoutPrRow>,
124}
125
126#[derive(Debug, Serialize)]
127struct CloseoutPrRow {
128    label: String,
129    merge_sha: String,
130    checks: &'static str,
131    required: String,
132    non_required_failures: String,
133}
134
135#[derive(Debug, Serialize)]
136struct SnapshotView<'a> {
137    marker: String,
138    heading: &'static str,
139    profile: &'a str,
140    path: Option<&'a str>,
141    commit: Option<&'a str>,
142    summary: Option<&'a str>,
143    details_summary: &'static str,
144    content: &'a str,
145    envelope_carrier: String,
146}
147
148#[derive(Debug, Serialize)]
149struct DashboardView<'a> {
150    title: &'static str,
151    status: String,
152    profile: &'a str,
153    target_scope: String,
154    current: String,
155    next_action: String,
156    validation: String,
157    linked_prs: String,
158    blockers: String,
159    approval: String,
160    source_link: String,
161    plan_link: String,
162    state_link: String,
163    session_link: String,
164    validation_link: String,
165    review_link: String,
166    show_review: bool,
167    closeout_link: String,
168    tracker_block: String,
169}
170
171fn render_tracker_block(title: Option<&str>, issue_url: Option<&str>) -> String {
172    let title = title.map(str::trim).filter(|value| !value.is_empty());
173    let issue_url = issue_url.map(str::trim).filter(|value| !value.is_empty());
174    if title.is_none() && issue_url.is_none() {
175        return String::new();
176    }
177    let mut out = vec![
178        String::new(),
179        "## Original Tracker".to_string(),
180        String::new(),
181    ];
182    if let Some(value) = title {
183        out.push(format!("- Title: {value}"));
184    }
185    if let Some(value) = issue_url {
186        out.push(format!("- Issue: {value}"));
187    }
188    out.join("\n")
189}
190
191fn render_dashboard_with_template(view: &DashboardView<'_>) -> String {
192    let mut engine = Engine::builder().build();
193    engine
194        .register_template(DASHBOARD_TEMPLATE_NAME, DASHBOARD_TEMPLATE)
195        .expect("dashboard template registers");
196    engine
197        .render(DASHBOARD_TEMPLATE_NAME, view)
198        .expect("dashboard template renders")
199}
200
201#[derive(Debug, Clone)]
202pub struct DashboardInput {
203    pub profile: RecordProfile,
204    pub status: String,
205    pub target_scope: String,
206    pub current: String,
207    pub next_action: String,
208    pub validation: String,
209    pub linked_prs: Vec<String>,
210    pub blockers: Vec<String>,
211    pub approval: String,
212    pub source_url: Option<String>,
213    pub plan_url: Option<String>,
214    pub state_url: Option<String>,
215    pub session_url: Option<String>,
216    pub validation_url: Option<String>,
217    pub review_url: Option<String>,
218    pub closeout_url: Option<String>,
219    pub title: Option<String>,
220    pub issue_url: Option<String>,
221}
222
223#[derive(Debug, Clone)]
224pub struct CommentInput {
225    pub profile: RecordProfile,
226    pub kind: LifecycleCommentKind,
227    pub path: Option<String>,
228    pub commit: Option<String>,
229    pub content: Option<String>,
230    pub title: Option<String>,
231    pub details_summary: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize)]
235pub struct LifecycleEvidence {
236    pub role: PayloadRole,
237    pub profile: PayloadProfile,
238    pub url: Option<String>,
239    pub created_at: Option<String>,
240    /// Stable status string derived from the structured payload (e.g.
241    /// state `status`, validation `overall`, review `decision`). `None`
242    /// when the role does not declare a status or the payload could not
243    /// be parsed.
244    pub status: Option<String>,
245    /// Parsed structured payload. `None` when the comment lacks a hidden
246    /// payload carrier or older `plan-issue-record-payload` fence; audit
247    /// still records the marker for visibility but downstream gates treat
248    /// missing payloads as unparseable evidence.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub payload: Option<RecordPayload>,
251}
252
253#[derive(Debug, Clone, Serialize)]
254pub struct UnsupportedMarker {
255    pub marker_prefix: String,
256    pub url: Option<String>,
257    pub created_at: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize)]
261pub struct RecordAudit {
262    pub profile_filter: Option<String>,
263    pub body_sections: BodySections,
264    /// Latest v2 lifecycle evidence indexed by role name (`source`,
265    /// `plan`, `state`, `session`, `validation`, `review`, `closeout`).
266    pub evidence: BTreeMap<String, LifecycleEvidence>,
267    /// Stable machine-readable codes for missing required evidence
268    /// (e.g. `source-missing`, `plan-missing`, `state-missing`).
269    pub missing_required: Vec<String>,
270    /// Pre-v2 markers seen during audit. Reported for visibility but not
271    /// counted as current lifecycle evidence.
272    pub unsupported_markers: Vec<UnsupportedMarker>,
273    pub recognized_count: usize,
274    #[serde(skip_serializing)]
275    pub evidence_text: String,
276}
277
278#[derive(Debug, Clone, Serialize)]
279pub struct BodySections {
280    pub current_dashboard: bool,
281    pub final_dashboard: bool,
282    pub durable_record: bool,
283    pub closeout_checks: bool,
284    pub task_decomposition: bool,
285}
286
287#[derive(Debug, Clone, Serialize)]
288pub struct CloseoutCheck {
289    pub check: String,
290    pub status: String,
291    pub detail: String,
292}
293
294#[derive(Debug)]
295struct CommentJson {
296    body: Option<String>,
297    url: Option<String>,
298    html_url: Option<String>,
299    created_at: Option<String>,
300}
301
302pub fn render_dashboard(input: DashboardInput) -> String {
303    let title = if input.status.trim().eq_ignore_ascii_case("complete") {
304        "## Final Dashboard"
305    } else {
306        "## Current Dashboard"
307    };
308
309    let show_review = input.profile == RecordProfile::Dispatch || input.review_url.is_some();
310    let tracker_block = render_tracker_block(input.title.as_deref(), input.issue_url.as_deref());
311
312    let view = DashboardView {
313        title,
314        status: input.status.trim().to_string(),
315        profile: input.profile.as_str(),
316        target_scope: input.target_scope.trim().to_string(),
317        current: input.current.trim().to_string(),
318        next_action: input.next_action.trim().to_string(),
319        validation: input.validation.trim().to_string(),
320        linked_prs: non_empty_join(&input.linked_prs, "none yet"),
321        blockers: non_empty_join(&input.blockers, "none"),
322        approval: input.approval.trim().to_string(),
323        source_link: dashboard_link(input.source_url.as_deref(), "source snapshot"),
324        plan_link: dashboard_link(input.plan_url.as_deref(), "plan snapshot"),
325        state_link: dashboard_link(input.state_url.as_deref(), "execution state"),
326        session_link: dashboard_link(input.session_url.as_deref(), "Execution Session"),
327        validation_link: dashboard_link(input.validation_url.as_deref(), "Validation Evidence"),
328        review_link: dashboard_link(input.review_url.as_deref(), "Review Evidence"),
329        show_review,
330        closeout_link: dashboard_link(input.closeout_url.as_deref(), "closeout"),
331        tracker_block,
332    };
333
334    render_dashboard_with_template(&view)
335}
336
337pub fn render_comment(input: CommentInput) -> Result<String, String> {
338    let marker = marker_for(input.profile, input.kind);
339    let heading = input
340        .title
341        .clone()
342        .unwrap_or_else(|| default_heading(input.profile, input.kind).to_string());
343    let content = input.content.unwrap_or_default();
344
345    let mut out = Vec::new();
346    out.push(marker);
347    out.push(String::new());
348    out.push(format!("## {heading}"));
349    out.push(String::new());
350
351    out.push(format!("- Profile: {}", input.profile.as_str()));
352    if let Some(path) = input
353        .path
354        .as_deref()
355        .filter(|value| !value.trim().is_empty())
356    {
357        out.push(format!("- Path: `{}`", path.trim()));
358    }
359    if let Some(commit) = input
360        .commit
361        .as_deref()
362        .filter(|value| !value.trim().is_empty())
363    {
364        out.push(format!("- Commit: `{}`", commit.trim()));
365    }
366
367    if matches!(
368        input.kind,
369        LifecycleCommentKind::Source | LifecycleCommentKind::Plan
370    ) {
371        if !out.last().is_some_and(String::is_empty) {
372            out.push(String::new());
373        }
374        out.push("- Snapshot mode: local committed Markdown".to_string());
375        out.push(String::new());
376        out.push("<details>".to_string());
377        out.push(format!(
378            "<summary>{}</summary>",
379            input
380                .details_summary
381                .as_deref()
382                .unwrap_or_else(|| default_details_summary(input.kind))
383        ));
384        out.push(String::new());
385        out.push(content);
386        out.push(String::new());
387        out.push("</details>".to_string());
388    } else if !content.trim().is_empty() {
389        out.push(String::new());
390        out.push(content);
391    }
392
393    Ok(finalize_markdown(out))
394}
395
396pub fn audit_record(
397    body: Option<&str>,
398    comments_json: &str,
399    profile_filter: Option<RecordProfile>,
400) -> Result<RecordAudit, String> {
401    let mut comments = parse_comments_json(comments_json)?;
402    comments.sort_by(|left, right| {
403        compare_created_at(right.created_at.as_deref(), left.created_at.as_deref())
404    });
405    let mut evidence: BTreeMap<String, LifecycleEvidence> = BTreeMap::new();
406    let mut unsupported_markers = Vec::new();
407    let mut recognized_count = 0usize;
408    let mut evidence_text = String::new();
409    if let Some(body) = body {
410        evidence_text.push_str(body);
411        evidence_text.push('\n');
412    }
413
414    for comment in comments {
415        let Some(comment_body) = comment.body.as_deref() else {
416            continue;
417        };
418        let Some(first_marker) = first_comment_marker(comment_body) else {
419            continue;
420        };
421        let Some(parsed) = parse_marker_line(first_marker) else {
422            continue;
423        };
424        match parsed {
425            MarkerParse::V2 { role, profile } => {
426                if profile_filter.is_some_and(|expected| profile != PayloadProfile::from(expected))
427                {
428                    continue;
429                }
430                let url = comment.url.clone().or_else(|| comment.html_url.clone());
431                let created_at = comment.created_at.clone();
432                let key = role.as_str().to_string();
433                if evidence.contains_key(&key) {
434                    continue;
435                }
436                let payload = match extract_payload(comment_body) {
437                    Ok(payload) => Some(payload),
438                    Err(err) if err.kind == PayloadErrorKind::NoFence => None,
439                    Err(err) => {
440                        return Err(format!(
441                            "comment at {} has malformed payload: {}",
442                            url.as_deref().unwrap_or("(unknown url)"),
443                            err.message
444                        ));
445                    }
446                };
447                if let Some(payload) = payload.as_ref() {
448                    validate_payload_data_for_role(payload.role, &payload.data).map_err(|err| {
449                        format!(
450                            "comment at {} has malformed payload for role `{}`: {}",
451                            url.as_deref().unwrap_or("(unknown url)"),
452                            payload.role.as_str(),
453                            err
454                        )
455                    })?;
456                }
457                let status = payload.as_ref().and_then(derive_status_from_payload);
458                let candidate = LifecycleEvidence {
459                    role,
460                    profile,
461                    url,
462                    created_at: created_at.clone(),
463                    status,
464                    payload,
465                };
466                evidence_text.push_str(comment_body);
467                evidence_text.push('\n');
468                recognized_count += 1;
469                evidence.insert(key, candidate);
470            }
471            MarkerParse::Unsupported { prefix } => {
472                unsupported_markers.push(UnsupportedMarker {
473                    marker_prefix: prefix,
474                    url: comment.url.or(comment.html_url),
475                    created_at: comment.created_at,
476                });
477            }
478        }
479    }
480
481    let mut missing_required = Vec::new();
482    for code in ["source-missing", "plan-missing", "state-missing"] {
483        let role_key = code.trim_end_matches("-missing");
484        if !evidence.contains_key(role_key) {
485            missing_required.push(code.to_string());
486        }
487    }
488
489    Ok(RecordAudit {
490        profile_filter: profile_filter.map(|profile| profile.as_str().to_string()),
491        body_sections: inspect_body_sections(body.unwrap_or_default()),
492        evidence,
493        missing_required,
494        unsupported_markers,
495        recognized_count,
496        evidence_text,
497    })
498}
499
500/// Return the latest visible comment body per lifecycle role, indexed by
501/// [`PayloadRole`]. Mirrors the latest-per-role selection inside
502/// [`audit_record`] and is the input the visible-completeness lint operates
503/// on (see [`crate::lifecycle_vnext::visible_lint`]).
504///
505/// `profile_filter` matches the same semantics as [`audit_record`] —
506/// comments whose marker carries a different profile are skipped.
507pub fn latest_role_bodies(
508    comments_json: &str,
509    profile_filter: Option<crate::commands::record::RecordProfile>,
510) -> Result<BTreeMap<PayloadRole, String>, String> {
511    let mut comments = parse_comments_json(comments_json)?;
512    comments.sort_by(|left, right| {
513        compare_created_at(right.created_at.as_deref(), left.created_at.as_deref())
514    });
515    let mut bodies: BTreeMap<PayloadRole, String> = BTreeMap::new();
516    for comment in comments {
517        let Some(body) = comment.body.as_deref() else {
518            continue;
519        };
520        let Some(first_marker) = first_comment_marker(body) else {
521            continue;
522        };
523        let Some(parsed) = parse_marker_line(first_marker) else {
524            continue;
525        };
526        if let MarkerParse::V2 { role, profile } = parsed {
527            if profile_filter.is_some_and(|expected| profile != PayloadProfile::from(expected)) {
528                continue;
529            }
530            bodies.entry(role).or_insert_with(|| body.to_string());
531        }
532    }
533    Ok(bodies)
534}
535
536/// Compare two RFC3339 created-at strings. `None` is considered older than
537/// any `Some(_)`. This keeps latest-by-role selection deterministic even
538/// when GitHub returns comments out of order.
539fn compare_created_at(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
540    match (left, right) {
541        (Some(l), Some(r)) => l.cmp(r),
542        (Some(_), None) => std::cmp::Ordering::Greater,
543        (None, Some(_)) => std::cmp::Ordering::Less,
544        (None, None) => std::cmp::Ordering::Equal,
545    }
546}
547
548/// Derive a stable visible status string from a parsed payload, suitable
549/// for dashboard rendering and closeout gating. Returns `None` when the
550/// role does not declare a status or the payload's status field is
551/// unparseable.
552fn derive_status_from_payload(payload: &RecordPayload) -> Option<String> {
553    match payload.role {
554        PayloadRole::State => payload
555            .parse_state()
556            .ok()
557            .and_then(|data| data.status.map(|s| status_state_label(s).to_string())),
558        PayloadRole::Validation => payload
559            .parse_validation()
560            .ok()
561            .map(|data| validation_overall_label(data.overall).to_string()),
562        PayloadRole::Review => payload
563            .parse_review()
564            .ok()
565            .map(|data| review_decision_label(data.decision).to_string()),
566        PayloadRole::Closeout => payload.parse_closeout().ok().map(|data| data.final_status),
567        PayloadRole::Source | PayloadRole::Plan | PayloadRole::Session => None,
568    }
569}
570
571fn status_state_label(value: StateStatus) -> &'static str {
572    match value {
573        StateStatus::InProgress => "in-progress",
574        StateStatus::Complete => "complete",
575        StateStatus::Blocked => "blocked",
576    }
577}
578
579fn validation_overall_label(value: ValidationOverall) -> &'static str {
580    match value {
581        ValidationOverall::Pass => "pass",
582        ValidationOverall::Fail => "fail",
583        ValidationOverall::Partial => "partial",
584    }
585}
586
587fn review_decision_label(value: ReviewDecision) -> &'static str {
588    match value {
589        ReviewDecision::Approve => "approve",
590        ReviewDecision::RequestChanges => "request-changes",
591        ReviewDecision::CommentsOnly => "comments-only",
592    }
593}
594
595/// Render the canonical dashboard for an issue-backed plan record from
596/// audit evidence alone — callers no longer need to pass every per-role
597/// URL. Returns a `## Final Dashboard` when the latest state payload
598/// reports `status=complete`, otherwise `## Current Dashboard`. Pending
599/// roles render as `pending` so the dashboard remains idempotent across
600/// repeated calls with the same evidence.
601pub fn render_dashboard_from_audit(
602    audit: &RecordAudit,
603    title: Option<&str>,
604    issue_url: Option<&str>,
605) -> String {
606    let state_evidence = audit.evidence.get("state");
607    let state_data = state_evidence
608        .and_then(|hit| hit.payload.as_ref())
609        .and_then(|payload| payload.parse_state().ok());
610    let is_complete = state_evidence
611        .and_then(|hit| hit.status.as_deref())
612        .map(|status| status.eq_ignore_ascii_case("complete"))
613        .unwrap_or(false);
614
615    let dashboard_title = if is_complete {
616        "## Final Dashboard"
617    } else {
618        "## Current Dashboard"
619    };
620
621    let profile_str = state_evidence
622        .map(|hit| hit.profile.as_str().to_string())
623        .or_else(|| {
624            audit
625                .evidence
626                .values()
627                .next()
628                .map(|hit| hit.profile.as_str().to_string())
629        })
630        .unwrap_or_else(|| "tracking".to_string());
631
632    let status_value = state_evidence
633        .and_then(|hit| hit.status.clone())
634        .unwrap_or_else(|| "pending".to_string());
635
636    let target_scope = state_data
637        .as_ref()
638        .and_then(|data| data.target_scope.clone())
639        .unwrap_or_else(|| "pending".to_string());
640    let current = state_data
641        .as_ref()
642        .and_then(|data| data.current.clone())
643        .unwrap_or_else(|| "pending".to_string());
644    let next_action = state_data
645        .as_ref()
646        .and_then(|data| data.next_action.clone())
647        .unwrap_or_else(|| "pending".to_string());
648    let validation_status = audit
649        .evidence
650        .get("validation")
651        .and_then(|hit| hit.status.clone())
652        .unwrap_or_else(|| "pending".to_string());
653    let linked_prs = state_data
654        .as_ref()
655        .map(|data| {
656            data.prs
657                .iter()
658                .map(|pr| pr.url.clone().unwrap_or_else(|| pr.pr_ref.clone()))
659                .collect::<Vec<_>>()
660        })
661        .unwrap_or_default();
662    let blockers = state_data
663        .as_ref()
664        .map(|data| data.blockers.clone())
665        .unwrap_or_default();
666    let approval = audit
667        .evidence
668        .get("closeout")
669        .and_then(|hit| hit.payload.as_ref())
670        .and_then(|payload| payload.parse_closeout().ok())
671        .and_then(|data| data.approval.comment_url)
672        .unwrap_or_else(|| "pending".to_string());
673
674    let is_dispatch_profile = profile_str == "dispatch";
675    let show_review = is_dispatch_profile || audit.evidence.contains_key("review");
676    let tracker_block = render_tracker_block(title, issue_url);
677
678    let view = DashboardView {
679        title: dashboard_title,
680        status: status_value,
681        profile: &profile_str,
682        target_scope,
683        current,
684        next_action,
685        validation: validation_status,
686        linked_prs: non_empty_join(&linked_prs, "none yet"),
687        blockers: non_empty_join(&blockers, "none"),
688        approval,
689        source_link: dashboard_link(evidence_url(audit, "source").as_deref(), "source snapshot"),
690        plan_link: dashboard_link(evidence_url(audit, "plan").as_deref(), "plan snapshot"),
691        state_link: dashboard_link(evidence_url(audit, "state").as_deref(), "execution state"),
692        session_link: dashboard_link(
693            evidence_url(audit, "session").as_deref(),
694            "Execution Session",
695        ),
696        validation_link: dashboard_link(
697            evidence_url(audit, "validation").as_deref(),
698            "Validation Evidence",
699        ),
700        review_link: dashboard_link(evidence_url(audit, "review").as_deref(), "Review Evidence"),
701        show_review,
702        closeout_link: dashboard_link(evidence_url(audit, "closeout").as_deref(), "closeout"),
703        tracker_block,
704    };
705
706    render_dashboard_with_template(&view)
707}
708
709fn evidence_url(audit: &RecordAudit, role: &str) -> Option<String> {
710    audit
711        .evidence
712        .get(role)
713        .and_then(|hit| hit.url.clone())
714        .filter(|value| !value.trim().is_empty())
715}
716
717fn non_empty_join(values: &[String], fallback: &str) -> String {
718    let joined = values
719        .iter()
720        .map(|value| value.trim())
721        .filter(|value| !value.is_empty())
722        .collect::<Vec<_>>()
723        .join(", ");
724    if joined.is_empty() {
725        fallback.to_string()
726    } else {
727        joined
728    }
729}
730
731fn dashboard_link(url: Option<&str>, label: &str) -> String {
732    match url.map(str::trim).filter(|value| !value.is_empty()) {
733        Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
734            format!("[{label}]({url})")
735        }
736        Some(value) => value.to_string(),
737        None => "pending".to_string(),
738    }
739}
740
741fn default_heading(profile: RecordProfile, kind: LifecycleCommentKind) -> &'static str {
742    match kind {
743        LifecycleCommentKind::Source => "Source Snapshot",
744        LifecycleCommentKind::Plan => "Plan Snapshot",
745        LifecycleCommentKind::State => "Execution State",
746        LifecycleCommentKind::Session => "Execution Session",
747        LifecycleCommentKind::Validation => "Validation Evidence",
748        LifecycleCommentKind::Review => "Review Evidence",
749        LifecycleCommentKind::Closeout => match profile {
750            RecordProfile::Tracking => "Tracking Issue Closeout",
751            RecordProfile::Dispatch => "Dispatch Issue Closeout",
752        },
753    }
754}
755
756fn default_details_summary(kind: LifecycleCommentKind) -> &'static str {
757    match kind {
758        LifecycleCommentKind::Source => "Source snapshot",
759        LifecycleCommentKind::Plan => "Plan snapshot",
760        _ => "Details",
761    }
762}
763
764/// Render the canonical v2 marker for a lifecycle comment kind.
765fn marker_for(profile: RecordProfile, kind: LifecycleCommentKind) -> String {
766    format!(
767        "<!-- plan-issue-record:v2 role={} profile={} -->",
768        kind.as_str(),
769        profile.as_str()
770    )
771}
772
773fn parse_comments_json(raw: &str) -> Result<Vec<CommentJson>, String> {
774    let value = serde_json::from_str::<Value>(raw)
775        .map_err(|err| format!("failed to parse comments JSON: {err}"))?;
776    let comments_value = match value {
777        Value::Object(mut object) => object
778            .remove("comments")
779            .ok_or_else(|| "comments JSON object is missing `comments`".to_string())?,
780        Value::Array(items) => Value::Array(items),
781        _ => {
782            return Err(
783                "comments JSON must be an array or an object with a `comments` array".to_string(),
784            );
785        }
786    };
787    let Value::Array(items) = comments_value else {
788        return Err("`comments` must be an array".to_string());
789    };
790
791    Ok(items
792        .into_iter()
793        .filter_map(|item| {
794            let Value::Object(mut object) = item else {
795                return None;
796            };
797            Some(CommentJson {
798                body: string_field(&mut object, "body"),
799                url: string_field(&mut object, "url"),
800                html_url: string_field(&mut object, "html_url"),
801                created_at: string_field(&mut object, "created_at")
802                    .or_else(|| string_field(&mut object, "createdAt")),
803            })
804        })
805        .collect())
806}
807
808fn string_field(object: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
809    object
810        .remove(key)
811        .and_then(|value| value.as_str().map(ToString::to_string))
812}
813
814fn first_comment_marker(body: &str) -> Option<&str> {
815    for line in body.lines() {
816        let trimmed = line.trim();
817        if trimmed.is_empty() {
818            continue;
819        }
820        if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
821            return Some(trimmed);
822        }
823        return None;
824    }
825    None
826}
827
828/// Outcome of marker parsing on a comment's first non-empty line.
829#[derive(Debug, Clone)]
830enum MarkerParse {
831    /// Canonical v2 marker.
832    V2 {
833        role: PayloadRole,
834        profile: PayloadProfile,
835    },
836    /// Pre-v2 marker family the v3 lifecycle no longer recognizes as
837    /// current lifecycle evidence (but tracks for reporting).
838    Unsupported { prefix: String },
839}
840
841fn parse_marker_line(marker: &str) -> Option<MarkerParse> {
842    let inner = marker.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
843    let attrs = parse_attrs(inner);
844
845    if let Some(rest) = inner.strip_prefix("plan-issue-record:v2") {
846        let _ = rest;
847        let role = attrs.get("role").and_then(|value| parse_role(value))?;
848        let profile = attrs
849            .get("profile")
850            .and_then(|value| parse_profile(value))
851            .unwrap_or(PayloadProfile::Tracking);
852        return Some(MarkerParse::V2 { role, profile });
853    }
854
855    // Known pre-v2 marker families. They are no longer recognized as
856    // current lifecycle evidence but are reported by audit so callers
857    // can identify v1-marker comments that need migration to v2.
858    for prefix in [
859        "issue-backed-plan:",
860        "plan-tracking-issue:",
861        "execute-from-tracking-issue:",
862        "execute-plan-tracking-issue:",
863        "tracking-issue-closeout:",
864        "plan-tracking-issue-closeout:",
865        "deliver-dispatch-plan:",
866        "dispatch-plan:",
867    ] {
868        if inner.starts_with(prefix) {
869            return Some(MarkerParse::Unsupported {
870                prefix: prefix.trim_end_matches(':').to_string(),
871            });
872        }
873    }
874
875    None
876}
877
878fn parse_role(value: &str) -> Option<PayloadRole> {
879    Some(match value {
880        "source" => PayloadRole::Source,
881        "plan" => PayloadRole::Plan,
882        "state" => PayloadRole::State,
883        "session" => PayloadRole::Session,
884        "validation" => PayloadRole::Validation,
885        "review" => PayloadRole::Review,
886        "closeout" => PayloadRole::Closeout,
887        _ => return None,
888    })
889}
890
891fn parse_profile(value: &str) -> Option<PayloadProfile> {
892    Some(match value {
893        "tracking" => PayloadProfile::Tracking,
894        "dispatch" => PayloadProfile::Dispatch,
895        _ => return None,
896    })
897}
898
899fn parse_attrs(marker: &str) -> BTreeMap<String, String> {
900    let mut attrs = BTreeMap::new();
901    for token in marker.split_whitespace().skip(1) {
902        let Some((key, value)) = token.split_once('=') else {
903            continue;
904        };
905        attrs.insert(
906            key.trim().to_string(),
907            value
908                .trim()
909                .trim_matches('"')
910                .trim_matches('\'')
911                .to_string(),
912        );
913    }
914    attrs
915}
916
917fn inspect_body_sections(body: &str) -> BodySections {
918    BodySections {
919        current_dashboard: body.contains("## Current Dashboard"),
920        final_dashboard: body.contains("## Final Dashboard"),
921        durable_record: body.contains("## Durable Record"),
922        closeout_checks: body.contains("## Closeout Checks"),
923        task_decomposition: body.contains("## Task Decomposition"),
924    }
925}
926
927fn finalize_markdown(lines: Vec<String>) -> String {
928    let mut rendered = lines.join("\n");
929    if !rendered.ends_with('\n') {
930        rendered.push('\n');
931    }
932    rendered
933}
934
935// -----------------------------------------------------------------------------
936// Structured lifecycle payload (issue-backed plan record contract v2)
937//
938// Each lifecycle comment carries one hidden payload carrier. Audit, dashboard
939// repair, and closeout gate evaluation consume the structured payload
940// exclusively. Visible Markdown around the carrier is human commentary only.
941// The older PAYLOAD_FENCE_INFO fenced block remains accepted for existing
942// records created before the hidden carrier renderer.
943// -----------------------------------------------------------------------------
944
945/// On-wire schema identity for v2 lifecycle payloads.
946///
947/// This is the active schema identity for today's lifecycle comments, not a
948/// promise that future state payload replacements keep v2 readable forever.
949pub const PAYLOAD_SCHEMA_V2: &str = "plan-issue-record.payload.v2";
950
951/// Older fenced-code-block info-string used to mark a lifecycle payload.
952pub const PAYLOAD_FENCE_INFO: &str = "plan-issue-record-payload";
953const PAYLOAD_COMMENT_PREFIX: &str = "plan-issue-record-payload:hex:";
954
955#[derive(
956    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, serde::Deserialize,
957)]
958#[serde(rename_all = "lowercase")]
959pub enum PayloadRole {
960    Source,
961    Plan,
962    State,
963    Session,
964    Validation,
965    Review,
966    Closeout,
967}
968
969impl PayloadRole {
970    pub fn as_str(self) -> &'static str {
971        match self {
972            Self::Source => "source",
973            Self::Plan => "plan",
974            Self::State => "state",
975            Self::Session => "session",
976            Self::Validation => "validation",
977            Self::Review => "review",
978            Self::Closeout => "closeout",
979        }
980    }
981}
982
983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
984#[serde(rename_all = "lowercase")]
985pub enum PayloadProfile {
986    Tracking,
987    Dispatch,
988}
989
990impl PayloadProfile {
991    pub fn as_str(self) -> &'static str {
992        match self {
993            Self::Tracking => "tracking",
994            Self::Dispatch => "dispatch",
995        }
996    }
997}
998
999impl From<RecordProfile> for PayloadProfile {
1000    fn from(value: RecordProfile) -> Self {
1001        match value {
1002            RecordProfile::Tracking => PayloadProfile::Tracking,
1003            RecordProfile::Dispatch => PayloadProfile::Dispatch,
1004        }
1005    }
1006}
1007
1008/// Envelope for every lifecycle comment payload.
1009#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1010pub struct RecordPayload {
1011    pub schema: String,
1012    pub role: PayloadRole,
1013    pub profile: PayloadProfile,
1014    #[serde(default)]
1015    pub updated_at: Option<String>,
1016    #[serde(default)]
1017    pub data: Value,
1018}
1019
1020#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1021pub struct SnapshotData {
1022    pub path: String,
1023    pub commit: String,
1024    #[serde(default)]
1025    pub title: Option<String>,
1026    #[serde(default)]
1027    pub summary: Option<String>,
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1031#[serde(rename_all = "kebab-case")]
1032pub enum StateStatus {
1033    InProgress,
1034    Complete,
1035    Blocked,
1036}
1037
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1039#[serde(rename_all = "kebab-case")]
1040pub enum TaskRowStatus {
1041    Pending,
1042    InProgress,
1043    Done,
1044    Deferred,
1045    Blocked,
1046    Waived,
1047}
1048
1049#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1050pub struct TaskRowPayload {
1051    pub id: String,
1052    pub status: TaskRowStatus,
1053    #[serde(default)]
1054    pub title: Option<String>,
1055}
1056
1057#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1058#[serde(rename_all = "lowercase")]
1059pub enum PrLifecycleStatus {
1060    Open,
1061    Merged,
1062    Closed,
1063}
1064
1065#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1066pub struct PrRefPayload {
1067    #[serde(rename = "ref")]
1068    pub pr_ref: String,
1069    #[serde(default)]
1070    pub url: Option<String>,
1071    pub status: PrLifecycleStatus,
1072}
1073
1074#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
1075pub struct StateData {
1076    pub status: Option<StateStatus>,
1077    #[serde(default)]
1078    pub target_scope: Option<String>,
1079    #[serde(default)]
1080    pub current: Option<String>,
1081    #[serde(default)]
1082    pub next_action: Option<String>,
1083    #[serde(default)]
1084    pub tasks: Vec<TaskRowPayload>,
1085    #[serde(default)]
1086    pub prs: Vec<PrRefPayload>,
1087    #[serde(default)]
1088    pub blockers: Vec<String>,
1089    #[serde(default)]
1090    pub links: BTreeMap<String, String>,
1091}
1092
1093#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1094pub struct SessionData {
1095    pub summary: String,
1096    #[serde(default)]
1097    pub highlights: Vec<String>,
1098    #[serde(default)]
1099    pub links: BTreeMap<String, String>,
1100}
1101
1102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1103#[serde(rename_all = "lowercase")]
1104pub enum ValidationOverall {
1105    Pass,
1106    Fail,
1107    Partial,
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1111#[serde(rename_all = "lowercase")]
1112pub enum ValidationCommandStatus {
1113    Pass,
1114    Fail,
1115    Skipped,
1116}
1117
1118#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1119pub struct ValidationCommand {
1120    pub command: String,
1121    pub status: ValidationCommandStatus,
1122    #[serde(default)]
1123    pub evidence: Option<String>,
1124}
1125
1126#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1127pub struct ValidationWaiver {
1128    pub command: String,
1129    pub reason: String,
1130}
1131
1132#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1133pub struct ValidationData {
1134    pub overall: ValidationOverall,
1135    #[serde(default)]
1136    pub commands: Vec<ValidationCommand>,
1137    #[serde(default)]
1138    pub waivers: Vec<ValidationWaiver>,
1139}
1140
1141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1142#[serde(rename_all = "kebab-case")]
1143pub enum ReviewDecision {
1144    Approve,
1145    RequestChanges,
1146    CommentsOnly,
1147}
1148
1149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1150#[serde(rename_all = "lowercase")]
1151pub enum FindingSeverity {
1152    Blocker,
1153    Major,
1154    Minor,
1155    Nit,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1159#[serde(rename_all = "kebab-case")]
1160pub enum FindingDisposition {
1161    Fixed,
1162    Residual,
1163    FollowUp,
1164    Deferred,
1165    NoAction,
1166}
1167
1168#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1169pub struct ReviewFinding {
1170    pub id: String,
1171    pub severity: FindingSeverity,
1172    pub disposition: FindingDisposition,
1173    pub summary: String,
1174}
1175
1176#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1177pub struct ReviewData {
1178    pub decision: ReviewDecision,
1179    #[serde(default)]
1180    pub lenses: Vec<String>,
1181    #[serde(default)]
1182    pub findings: Vec<ReviewFinding>,
1183    #[serde(default)]
1184    pub outcome_comment_url: Option<String>,
1185}
1186
1187#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1188pub struct ApprovalEvidence {
1189    #[serde(default)]
1190    pub comment_url: Option<String>,
1191    #[serde(default)]
1192    pub approver: Option<String>,
1193}
1194
1195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1196#[serde(rename_all = "lowercase")]
1197pub enum CheckStatus {
1198    Pass,
1199    Fail,
1200    None,
1201}
1202
1203#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1204pub struct LinkedPrEvidence {
1205    #[serde(rename = "ref")]
1206    pub pr_ref: String,
1207    #[serde(default)]
1208    pub url: Option<String>,
1209    #[serde(default)]
1210    pub merge_sha: Option<String>,
1211    /// Aggregate rollup over every PR check (required and non-required
1212    /// combined). Kept for backward compatibility with the closeout
1213    /// payload schema; the close gate now consults `required_state`
1214    /// first and only falls back to `checks` when required-check state
1215    /// is unknown.
1216    pub checks: CheckStatus,
1217    /// Required-check rollup when the provider exposes the
1218    /// required/non-required distinction. `None` means the adapter
1219    /// could not resolve a required-only summary (e.g. GitLab today,
1220    /// or a degraded `gh` call), in which case the gate falls back to
1221    /// the aggregate `checks` value.
1222    #[serde(default)]
1223    pub required_state: Option<CheckStatus>,
1224    /// Number of required checks reported by the provider. `None`
1225    /// when required-check classification is unavailable; `Some(0)`
1226    /// means the PR has zero required checks.
1227    #[serde(default)]
1228    pub required_count: Option<u32>,
1229    /// Names of non-required checks that ended in a failure-class
1230    /// state. Surfaced as informational evidence in the closeout
1231    /// comment; never blocks the gate on its own.
1232    #[serde(default)]
1233    pub non_required_failures: Vec<String>,
1234}
1235
1236#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1237pub struct CloseoutData {
1238    pub final_status: String,
1239    pub approval: ApprovalEvidence,
1240    #[serde(default)]
1241    pub linked_prs: Vec<LinkedPrEvidence>,
1242    #[serde(default)]
1243    pub non_required_check_override: Option<Value>,
1244    #[serde(default)]
1245    pub final_validation_url: Option<String>,
1246    #[serde(default)]
1247    pub notes: Option<String>,
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq)]
1251pub enum PayloadErrorKind {
1252    NoFence,
1253    MultipleFences,
1254    SchemaMismatch,
1255    InvalidJson,
1256}
1257
1258#[derive(Debug, Clone)]
1259pub struct PayloadError {
1260    pub kind: PayloadErrorKind,
1261    pub message: String,
1262}
1263
1264impl PayloadError {
1265    fn new(kind: PayloadErrorKind, message: impl Into<String>) -> Self {
1266        Self {
1267            kind,
1268            message: message.into(),
1269        }
1270    }
1271}
1272
1273impl std::fmt::Display for PayloadError {
1274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1275        write!(f, "{}", self.message)
1276    }
1277}
1278
1279impl std::error::Error for PayloadError {}
1280
1281/// Extract the structured lifecycle payload carried inside `comment_body`.
1282///
1283/// - Returns `Ok(payload)` on a single well-formed hidden carrier or older
1284///   `plan-issue-record-payload` fence whose envelope `schema` matches
1285///   [`PAYLOAD_SCHEMA_V2`]. The older fence support is carrier-level support
1286///   for current v2 records; it is not a future old-schema reader contract.
1287/// - Returns `Err(NoFence)` when the comment does not contain either payload
1288///   carrier.
1289/// - Returns `Err(MultipleFences)` when multiple payload carriers are present;
1290///   each comment carries at most one payload.
1291/// - Returns `Err(SchemaMismatch)` when the payload parses but its `schema`
1292///   does not match the v2 wire identity.
1293/// - Returns `Err(InvalidJson)` when the payload body is not valid JSON or
1294///   does not deserialize into the envelope.
1295pub fn extract_payload(comment_body: &str) -> Result<RecordPayload, PayloadError> {
1296    let carriers = collect_payload_comment_carriers(comment_body)?;
1297    let fences = collect_payload_fences(comment_body);
1298    let payload_count = carriers.len() + fences.len();
1299    if payload_count == 0 {
1300        return Err(PayloadError::new(
1301            PayloadErrorKind::NoFence,
1302            "no plan-issue-record-payload carrier or fence in comment body",
1303        ));
1304    }
1305    if payload_count > 1 {
1306        return Err(PayloadError::new(
1307            PayloadErrorKind::MultipleFences,
1308            "multiple plan-issue-record-payload carriers or fences in comment body",
1309        ));
1310    }
1311
1312    let raw = carriers.first().or_else(|| fences.first()).ok_or_else(|| {
1313        PayloadError::new(
1314            PayloadErrorKind::NoFence,
1315            "no plan-issue-record-payload carrier or fence in comment body",
1316        )
1317    })?;
1318    let payload: RecordPayload = serde_json::from_str(raw)
1319        .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))?;
1320    if payload.schema != PAYLOAD_SCHEMA_V2 {
1321        return Err(PayloadError::new(
1322            PayloadErrorKind::SchemaMismatch,
1323            format!(
1324                "expected schema `{PAYLOAD_SCHEMA_V2}`, got `{}`",
1325                payload.schema
1326            ),
1327        ));
1328    }
1329    Ok(payload)
1330}
1331
1332pub(crate) fn raw_payload_marker_count(body: &str) -> usize {
1333    raw_payload_comment_marker_count(body) + raw_payload_fence_marker_count(body)
1334}
1335
1336fn raw_payload_comment_marker_count(body: &str) -> usize {
1337    body.lines()
1338        .filter(|line| {
1339            line.trim()
1340                .strip_prefix("<!--")
1341                .and_then(|value| value.strip_suffix("-->"))
1342                .is_some_and(|inner| inner.trim().starts_with(PAYLOAD_COMMENT_PREFIX))
1343        })
1344        .count()
1345}
1346
1347fn raw_payload_fence_marker_count(body: &str) -> usize {
1348    body.lines()
1349        .filter(|line| {
1350            line.trim_start()
1351                .strip_prefix("```")
1352                .is_some_and(|rest| rest.trim() == PAYLOAD_FENCE_INFO)
1353        })
1354        .count()
1355}
1356
1357fn collect_payload_comment_carriers(body: &str) -> Result<Vec<String>, PayloadError> {
1358    let mut out = Vec::new();
1359    let mut details_depth = 0usize;
1360    for line in body.lines() {
1361        if update_details_depth(line, &mut details_depth) {
1362            continue;
1363        }
1364        if details_depth > 0 {
1365            continue;
1366        }
1367        let trimmed = line.trim();
1368        let Some(inner) = trimmed
1369            .strip_prefix("<!--")
1370            .and_then(|value| value.strip_suffix("-->"))
1371        else {
1372            continue;
1373        };
1374        let inner = inner.trim();
1375        let Some(encoded) = inner.strip_prefix(PAYLOAD_COMMENT_PREFIX) else {
1376            continue;
1377        };
1378        let payload = decode_hex(encoded.trim()).map_err(|err| {
1379            PayloadError::new(
1380                PayloadErrorKind::InvalidJson,
1381                format!("invalid hidden payload carrier: {err}"),
1382            )
1383        })?;
1384        let payload = String::from_utf8(payload).map_err(|err| {
1385            PayloadError::new(
1386                PayloadErrorKind::InvalidJson,
1387                format!("hidden payload carrier is not UTF-8: {err}"),
1388            )
1389        })?;
1390        out.push(payload);
1391    }
1392    Ok(out)
1393}
1394
1395fn collect_payload_fences(body: &str) -> Vec<String> {
1396    let mut out = Vec::new();
1397    let mut current: Option<Vec<String>> = None;
1398    let mut details_depth = 0usize;
1399    for line in body.lines() {
1400        let trimmed = line.trim_start();
1401        if let Some(buf) = current.as_mut() {
1402            if trimmed.starts_with("```") {
1403                let mut block = String::new();
1404                for chunk in buf.iter() {
1405                    if !block.is_empty() {
1406                        block.push('\n');
1407                    }
1408                    block.push_str(chunk);
1409                }
1410                out.push(block);
1411                current = None;
1412            } else {
1413                buf.push(line.to_string());
1414            }
1415        } else {
1416            if update_details_depth(line, &mut details_depth) {
1417                continue;
1418            }
1419            if details_depth > 0 {
1420                continue;
1421            }
1422            if let Some(rest) = trimmed.strip_prefix("```")
1423                && rest.trim() == PAYLOAD_FENCE_INFO
1424            {
1425                current = Some(Vec::new());
1426            }
1427        }
1428    }
1429    out
1430}
1431
1432fn update_details_depth(line: &str, depth: &mut usize) -> bool {
1433    let trimmed = line.trim();
1434    if trimmed.starts_with("<details") {
1435        *depth += 1;
1436        return true;
1437    }
1438    if trimmed.starts_with("</details>") {
1439        *depth = depth.saturating_sub(1);
1440        return true;
1441    }
1442    false
1443}
1444
1445fn encode_hex(bytes: &[u8]) -> String {
1446    const HEX: &[u8; 16] = b"0123456789abcdef";
1447    let mut out = String::with_capacity(bytes.len() * 2);
1448    for byte in bytes {
1449        out.push(HEX[(byte >> 4) as usize] as char);
1450        out.push(HEX[(byte & 0x0f) as usize] as char);
1451    }
1452    out
1453}
1454
1455fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
1456    if !input.len().is_multiple_of(2) {
1457        return Err("hex payload has odd length".to_string());
1458    }
1459    let mut out = Vec::with_capacity(input.len() / 2);
1460    let bytes = input.as_bytes();
1461    for pair in bytes.chunks_exact(2) {
1462        let hi = hex_value(pair[0])
1463            .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[0])))?;
1464        let lo = hex_value(pair[1])
1465            .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[1])))?;
1466        out.push((hi << 4) | lo);
1467    }
1468    Ok(out)
1469}
1470
1471fn hex_value(byte: u8) -> Option<u8> {
1472    match byte {
1473        b'0'..=b'9' => Some(byte - b'0'),
1474        b'a'..=b'f' => Some(byte - b'a' + 10),
1475        b'A'..=b'F' => Some(byte - b'A' + 10),
1476        _ => None,
1477    }
1478}
1479
1480impl RecordPayload {
1481    pub fn parse_state(&self) -> Result<StateData, PayloadError> {
1482        self.decode_data(PayloadRole::State)
1483    }
1484
1485    pub fn parse_session(&self) -> Result<SessionData, PayloadError> {
1486        self.decode_data(PayloadRole::Session)
1487    }
1488
1489    pub fn parse_validation(&self) -> Result<ValidationData, PayloadError> {
1490        self.decode_data(PayloadRole::Validation)
1491    }
1492
1493    pub fn parse_review(&self) -> Result<ReviewData, PayloadError> {
1494        self.decode_data(PayloadRole::Review)
1495    }
1496
1497    pub fn parse_closeout(&self) -> Result<CloseoutData, PayloadError> {
1498        self.decode_data(PayloadRole::Closeout)
1499    }
1500
1501    pub fn parse_snapshot(&self) -> Result<SnapshotData, PayloadError> {
1502        if !matches!(self.role, PayloadRole::Source | PayloadRole::Plan) {
1503            return Err(PayloadError::new(
1504                PayloadErrorKind::SchemaMismatch,
1505                format!(
1506                    "expected source or plan payload, got `{}`",
1507                    self.role.as_str()
1508                ),
1509            ));
1510        }
1511        serde_json::from_value::<SnapshotData>(self.data.clone())
1512            .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1513    }
1514
1515    fn decode_data<T: serde::de::DeserializeOwned>(
1516        &self,
1517        expected: PayloadRole,
1518    ) -> Result<T, PayloadError> {
1519        if self.role != expected {
1520            return Err(PayloadError::new(
1521                PayloadErrorKind::SchemaMismatch,
1522                format!(
1523                    "expected role `{}`, got `{}`",
1524                    expected.as_str(),
1525                    self.role.as_str()
1526                ),
1527            ));
1528        }
1529        serde_json::from_value::<T>(self.data.clone())
1530            .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1531    }
1532}
1533
1534pub fn validate_payload_data_for_kind(
1535    kind: LifecycleCommentKind,
1536    data: &Value,
1537) -> Result<(), PayloadError> {
1538    validate_payload_data_for_role(payload_role_for_kind(kind), data)
1539}
1540
1541fn validate_payload_data_for_role(role: PayloadRole, data: &Value) -> Result<(), PayloadError> {
1542    let payload = RecordPayload {
1543        schema: PAYLOAD_SCHEMA_V2.to_string(),
1544        role,
1545        profile: PayloadProfile::Tracking,
1546        updated_at: None,
1547        data: data.clone(),
1548    };
1549    validate_payload_data(&payload)
1550}
1551
1552fn validate_payload_data(payload: &RecordPayload) -> Result<(), PayloadError> {
1553    match payload.role {
1554        PayloadRole::Source | PayloadRole::Plan => payload.parse_snapshot().map(|_| ()),
1555        PayloadRole::State => payload.parse_state().map(|_| ()),
1556        PayloadRole::Session => payload.parse_session().map(|_| ()),
1557        PayloadRole::Validation => payload.parse_validation().map(|_| ()),
1558        PayloadRole::Review => payload.parse_review().map(|_| ()),
1559        PayloadRole::Closeout => payload.parse_closeout().map(|_| ()),
1560    }
1561}
1562
1563// -----------------------------------------------------------------------------
1564// v2 provider-backed renderers (Sprint 3)
1565//
1566// `render_record_snapshot_comment` and `render_record_post_comment` produce the
1567// canonical Markdown body for `record open` and `record post`: every comment
1568// carries the v2 marker on its first line plus a hidden payload carrier as the
1569// structured source of truth. Audit still accepts the older visible payload
1570// fence for records created before this renderer was fixed.
1571// -----------------------------------------------------------------------------
1572
1573fn payload_role_for_kind(kind: LifecycleCommentKind) -> PayloadRole {
1574    match kind {
1575        LifecycleCommentKind::Source => PayloadRole::Source,
1576        LifecycleCommentKind::Plan => PayloadRole::Plan,
1577        LifecycleCommentKind::State => PayloadRole::State,
1578        LifecycleCommentKind::Session => PayloadRole::Session,
1579        LifecycleCommentKind::Validation => PayloadRole::Validation,
1580        LifecycleCommentKind::Review => PayloadRole::Review,
1581        LifecycleCommentKind::Closeout => PayloadRole::Closeout,
1582    }
1583}
1584
1585fn render_payload_carrier(envelope: &RecordPayload) -> Result<String, String> {
1586    let envelope_json = serde_json::to_string(envelope).map_err(|err| err.to_string())?;
1587    Ok(format!(
1588        "<!-- {PAYLOAD_COMMENT_PREFIX}{} -->",
1589        encode_hex(envelope_json.as_bytes())
1590    ))
1591}
1592
1593/// Render the canonical v2 source/plan snapshot comment used by
1594/// `record open`. The body carries the v2 marker, visible details, and a
1595/// hidden structured payload carrying [`SnapshotData`].
1596pub fn render_record_snapshot_comment(
1597    profile: RecordProfile,
1598    kind: LifecycleCommentKind,
1599    snapshot: &SnapshotData,
1600    content: &str,
1601    updated_at: Option<&str>,
1602) -> Result<String, String> {
1603    if !matches!(
1604        kind,
1605        LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1606    ) {
1607        return Err(format!(
1608            "render_record_snapshot_comment: expected source or plan kind, got `{}`",
1609            kind.as_str()
1610        ));
1611    }
1612
1613    let envelope = RecordPayload {
1614        schema: PAYLOAD_SCHEMA_V2.to_string(),
1615        role: payload_role_for_kind(kind),
1616        profile: PayloadProfile::from(profile),
1617        updated_at: updated_at.map(str::to_string),
1618        data: serde_json::to_value(snapshot).map_err(|err| err.to_string())?,
1619    };
1620    let envelope_carrier = render_payload_carrier(&envelope)?;
1621
1622    let path = Some(snapshot.path.trim()).filter(|value| !value.is_empty());
1623    let commit = Some(snapshot.commit.trim()).filter(|value| !value.is_empty());
1624    let summary = snapshot
1625        .summary
1626        .as_deref()
1627        .map(str::trim)
1628        .filter(|value| !value.is_empty());
1629
1630    let view = SnapshotView {
1631        marker: marker_for(profile, kind),
1632        heading: default_heading(profile, kind),
1633        profile: profile.as_str(),
1634        path,
1635        commit,
1636        summary,
1637        details_summary: default_details_summary(kind),
1638        content,
1639        envelope_carrier,
1640    };
1641
1642    let mut engine = Engine::builder().build();
1643    engine
1644        .register_template(SNAPSHOT_TEMPLATE_NAME, SNAPSHOT_TEMPLATE)
1645        .map_err(|err| format!("snapshot template register failed: {err}"))?;
1646    engine
1647        .render(SNAPSHOT_TEMPLATE_NAME, &view)
1648        .map_err(|err| format!("snapshot template render failed: {err}"))
1649}
1650
1651/// Extract the verbatim file content embedded in a `source`/`plan` snapshot
1652/// comment's `<details>` block. This is the inverse of the content placement
1653/// in [`render_record_snapshot_comment`]: that renderer emits
1654/// `<details>`/`<summary>…</summary>` then a blank line, the file content
1655/// verbatim, a blank line, and `</details>` (see `snapshot.md.tera`). This
1656/// returns the lines between the blank line after `<summary>` and the
1657/// snapshot wrapper's matching `</details>`, accounting for any `<details>`
1658/// blocks nested inside the content itself.
1659///
1660/// The renderer surrounds the content with structural blank lines, so the
1661/// trailing blank padding is dropped and the result is normalized to end with
1662/// exactly one `\n`. Bundle Markdown files conventionally end with a single
1663/// trailing newline, so this round-trips byte-for-byte for well-formed files.
1664pub fn extract_snapshot_content(comment_body: &str) -> Result<String, String> {
1665    let lines: Vec<&str> = comment_body.lines().collect();
1666    let open_idx = lines
1667        .iter()
1668        .position(|line| line.trim().starts_with("<details"))
1669        .ok_or_else(|| "snapshot comment has no <details> block".to_string())?;
1670
1671    // The wrapper opener is followed by `<summary>…</summary>` and one blank
1672    // line of template padding before the content begins.
1673    let mut start = open_idx + 1;
1674    if start < lines.len() && lines[start].trim_start().starts_with("<summary") {
1675        start += 1;
1676    }
1677    if start < lines.len() && lines[start].trim().is_empty() {
1678        start += 1;
1679    }
1680
1681    // Collect content until the wrapper's matching `</details>`, tracking
1682    // nested `<details>` blocks that may appear inside the file content.
1683    let mut depth = 1usize;
1684    let mut content: Vec<&str> = Vec::new();
1685    let mut closed = false;
1686    for line in &lines[start..] {
1687        let trimmed = line.trim();
1688        if trimmed.starts_with("<details") {
1689            depth += 1;
1690        } else if trimmed.starts_with("</details>") {
1691            depth -= 1;
1692            if depth == 0 {
1693                closed = true;
1694                break;
1695            }
1696        }
1697        content.push(line);
1698    }
1699    if !closed {
1700        return Err("snapshot <details> block is not closed".to_string());
1701    }
1702
1703    // Drop the renderer's trailing blank-line padding, then normalize to a
1704    // single trailing newline.
1705    while content.last().is_some_and(|line| line.trim().is_empty()) {
1706        content.pop();
1707    }
1708    let mut out = content.join("\n");
1709    out.push('\n');
1710    Ok(out)
1711}
1712
1713/// Render the canonical v2 lifecycle comment used by `record post` for
1714/// state, session, validation, review, and closeout kinds. Source/plan
1715/// kinds are rejected because `record open` owns them.
1716pub fn render_record_post_comment(
1717    profile: RecordProfile,
1718    kind: LifecycleCommentKind,
1719    payload_data: Value,
1720    summary: Option<&str>,
1721    updated_at: Option<&str>,
1722) -> Result<String, String> {
1723    render_record_post_comment_with_display(
1724        profile,
1725        kind,
1726        payload_data,
1727        None,
1728        summary,
1729        updated_at,
1730        TaskLedgerDisplay::Auto,
1731    )
1732}
1733
1734/// Controls how the visible Execution State header is produced.
1735#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1736pub enum StateHeaderMode {
1737    /// Preserve the authored execution-state header verbatim. Used by
1738    /// `record open` and `record post`, where the caller supplies the canonical
1739    /// execution-state markdown and expects its metadata bullets preserved.
1740    Authored,
1741    /// Re-render the header (`Status` / `Target scope` / `Current task` /
1742    /// `Next task`) from the derived payload. Used by `tracking checkpoint`,
1743    /// where the controller owns deriving live state from run-state so a
1744    /// completed plan never keeps a frozen pre-flight header
1745    /// (graysurf/plan-tracking-testbed#54 / sympoies/nils-cli#700).
1746    DeriveFromPayload,
1747}
1748
1749/// `execution_state` carries the canonical execution-state Markdown for
1750/// `state` comments; `summary` carries free-form commentary rendered after
1751/// the comment header, above the generated body. When both are given for a
1752/// `state` comment, the summary renders above the execution-state document.
1753pub fn render_record_post_comment_with_display(
1754    profile: RecordProfile,
1755    kind: LifecycleCommentKind,
1756    payload_data: Value,
1757    execution_state: Option<&str>,
1758    summary: Option<&str>,
1759    updated_at: Option<&str>,
1760    task_ledger_display: TaskLedgerDisplay,
1761) -> Result<String, String> {
1762    render_record_post_comment_with_display_mode(
1763        profile,
1764        kind,
1765        payload_data,
1766        execution_state,
1767        summary,
1768        updated_at,
1769        task_ledger_display,
1770        StateHeaderMode::Authored,
1771    )
1772}
1773
1774#[allow(clippy::too_many_arguments)]
1775pub fn render_record_post_comment_with_display_mode(
1776    profile: RecordProfile,
1777    kind: LifecycleCommentKind,
1778    payload_data: Value,
1779    execution_state: Option<&str>,
1780    summary: Option<&str>,
1781    updated_at: Option<&str>,
1782    task_ledger_display: TaskLedgerDisplay,
1783    header_mode: StateHeaderMode,
1784) -> Result<String, String> {
1785    if matches!(
1786        kind,
1787        LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1788    ) {
1789        return Err(format!(
1790            "render_record_post_comment: source/plan kinds are owned by `record open`, got `{}`",
1791            kind.as_str()
1792        ));
1793    }
1794    if execution_state.is_some() && kind != LifecycleCommentKind::State {
1795        return Err(format!(
1796            "render_record_post_comment: execution-state markdown is only valid for `state`, got `{}`",
1797            kind.as_str()
1798        ));
1799    }
1800
1801    let visible_content = render_visible_post_content(
1802        kind,
1803        &payload_data,
1804        execution_state,
1805        summary,
1806        task_ledger_display,
1807        header_mode,
1808    )?;
1809    let envelope = RecordPayload {
1810        schema: PAYLOAD_SCHEMA_V2.to_string(),
1811        role: payload_role_for_kind(kind),
1812        profile: PayloadProfile::from(profile),
1813        updated_at: updated_at.map(str::to_string),
1814        data: payload_data,
1815    };
1816    let envelope_carrier = render_payload_carrier(&envelope)?;
1817
1818    let view = PostCommentView {
1819        marker: marker_for(profile, kind),
1820        heading: default_heading(profile, kind),
1821        profile: profile.as_str(),
1822        visible_content,
1823        envelope_carrier,
1824    };
1825
1826    let mut engine = Engine::builder().build();
1827    engine
1828        .register_template(POST_COMMENT_TEMPLATE_NAME, POST_COMMENT_TEMPLATE)
1829        .map_err(|err| format!("post_comment template register failed: {err}"))?;
1830    engine
1831        .render(POST_COMMENT_TEMPLATE_NAME, &view)
1832        .map_err(|err| format!("post_comment template render failed: {err}"))
1833}
1834
1835fn render_visible_post_content(
1836    kind: LifecycleCommentKind,
1837    payload_data: &Value,
1838    execution_state: Option<&str>,
1839    summary: Option<&str>,
1840    task_ledger_display: TaskLedgerDisplay,
1841    header_mode: StateHeaderMode,
1842) -> Result<String, String> {
1843    let execution_state = execution_state
1844        .map(str::trim)
1845        .filter(|value| !value.is_empty());
1846    let summary = summary.map(str::trim).filter(|value| !value.is_empty());
1847    let generated = match kind {
1848        LifecycleCommentKind::State => {
1849            let state = serde_json::from_value::<StateData>(payload_data.clone())
1850                .map_err(|err| format!("state payload invalid for visible rendering: {err}"))?;
1851            match (execution_state, summary) {
1852                (Some(document), summary) => {
1853                    let rendered = render_state_markdown_with_task_ledger_display(
1854                        document,
1855                        task_ledger_display,
1856                        &state,
1857                        header_mode,
1858                    )?;
1859                    combine_summary_and_generated(summary, rendered)
1860                }
1861                // Single-input path: a summary that carries the ledger
1862                // document is rendered as the execution-state body. Still
1863                // load-bearing for `record open` seeding and `tracking
1864                // checkpoint`, which pass the document through `summary`.
1865                (None, Some(text)) if text.contains("## Task Ledger") => {
1866                    render_state_markdown_with_task_ledger_display(
1867                        text,
1868                        task_ledger_display,
1869                        &state,
1870                        header_mode,
1871                    )?
1872                }
1873                (None, Some(text)) => text.to_string(),
1874                (None, None) => render_state_payload_visible(&state),
1875            }
1876        }
1877        LifecycleCommentKind::Session => {
1878            let session = serde_json::from_value::<SessionData>(payload_data.clone())
1879                .map_err(|err| format!("session payload invalid for visible rendering: {err}"))?;
1880            combine_summary_and_generated(
1881                summary,
1882                render_session_payload_visible(&session, payload_data),
1883            )
1884        }
1885        LifecycleCommentKind::Validation => {
1886            let validation = serde_json::from_value::<ValidationData>(payload_data.clone())
1887                .map_err(|err| {
1888                    format!("validation payload invalid for visible rendering: {err}")
1889                })?;
1890            combine_summary_and_generated(summary, render_validation_payload_visible(&validation))
1891        }
1892        LifecycleCommentKind::Review => {
1893            let review = serde_json::from_value::<ReviewData>(payload_data.clone())
1894                .map_err(|err| format!("review payload invalid for visible rendering: {err}"))?;
1895            combine_summary_and_generated(summary, render_review_payload_visible(&review))
1896        }
1897        LifecycleCommentKind::Closeout => {
1898            let closeout = serde_json::from_value::<CloseoutData>(payload_data.clone())
1899                .map_err(|err| format!("closeout payload invalid for visible rendering: {err}"))?;
1900            combine_summary_and_generated(summary, render_closeout_payload_visible(&closeout))
1901        }
1902        LifecycleCommentKind::Source | LifecycleCommentKind::Plan => unreachable!(),
1903    };
1904
1905    if generated.trim().is_empty() {
1906        return Err(format!(
1907            "`record post --kind {}` would render no visible lifecycle content",
1908            kind.as_str()
1909        ));
1910    }
1911    Ok(generated)
1912}
1913
1914fn combine_summary_and_generated(summary: Option<&str>, generated: String) -> String {
1915    match (summary, generated.trim().is_empty()) {
1916        (Some(text), false) => format!("{}\n\n{}", text.trim(), generated.trim()),
1917        (Some(text), true) => text.trim().to_string(),
1918        (None, _) => generated,
1919    }
1920}
1921
1922fn render_state_markdown_with_task_ledger_display(
1923    markdown: &str,
1924    display: TaskLedgerDisplay,
1925    state: &StateData,
1926    header_mode: StateHeaderMode,
1927) -> Result<String, String> {
1928    let markdown = normalize_state_markdown_for_comment(markdown)?;
1929    // On the `tracking checkpoint` path, re-render the authored header
1930    // (everything before the first `## ` section) from the derived payload so a
1931    // completed plan reflects live progress instead of a frozen pre-flight
1932    // header (graysurf/plan-tracking-testbed#54 / sympoies/nils-cli#700).
1933    // `record open` / `record post` keep the authored header verbatim. Authored
1934    // sections — `## Task Ledger`, `## Validation Plan`, … — are preserved
1935    // either way.
1936    let markdown = match header_mode {
1937        StateHeaderMode::DeriveFromPayload => replace_state_header_from_payload(&markdown, state),
1938        StateHeaderMode::Authored => markdown,
1939    };
1940    let effective = match display {
1941        TaskLedgerDisplay::Expanded => TaskLedgerDisplay::Expanded,
1942        TaskLedgerDisplay::Collapsed => TaskLedgerDisplay::Collapsed,
1943        TaskLedgerDisplay::Open => TaskLedgerDisplay::Open,
1944        TaskLedgerDisplay::Auto => {
1945            if is_terminal_state(state) {
1946                TaskLedgerDisplay::Expanded
1947            } else {
1948                TaskLedgerDisplay::Collapsed
1949            }
1950        }
1951    };
1952    if effective == TaskLedgerDisplay::Expanded {
1953        return Ok(markdown);
1954    }
1955    // `Collapsed` renders a closed fold; `Open` keeps the same fold toggle but
1956    // adds the `open` attribute so the ledger is visible by default.
1957    let details_open_tag = match effective {
1958        TaskLedgerDisplay::Open => "<details open>",
1959        _ => "<details>",
1960    };
1961
1962    let lines: Vec<&str> = markdown.lines().collect();
1963    let Some(start) = lines
1964        .iter()
1965        .position(|line| line.trim() == "## Task Ledger")
1966    else {
1967        return Err("execution-state markdown is missing `## Task Ledger`".to_string());
1968    };
1969    let end = lines
1970        .iter()
1971        .enumerate()
1972        .skip(start + 1)
1973        .find_map(|(idx, line)| {
1974            if line.starts_with("## ") {
1975                Some(idx)
1976            } else {
1977                None
1978            }
1979        })
1980        .unwrap_or(lines.len());
1981    let body = lines[start + 1..end].join("\n").trim().to_string();
1982    if body.is_empty() {
1983        return Err("execution-state Task Ledger section is empty".to_string());
1984    }
1985
1986    let mut out = Vec::new();
1987    out.extend(lines[..=start].iter().map(|line| (*line).to_string()));
1988    out.push(String::new());
1989    out.push(details_open_tag.to_string());
1990    out.push("<summary>Show task ledger</summary>".to_string());
1991    out.push(String::new());
1992    out.push(body);
1993    out.push(String::new());
1994    out.push("</details>".to_string());
1995    if end < lines.len() {
1996        out.push(String::new());
1997        out.extend(lines[end..].iter().map(|line| (*line).to_string()));
1998    }
1999    Ok(finalize_markdown(out).trim().to_string())
2000}
2001
2002fn normalize_state_markdown_for_comment(markdown: &str) -> Result<String, String> {
2003    let stripped = markdown
2004        .trim()
2005        .lines()
2006        .filter(|line| {
2007            let trimmed = line.trim();
2008            !trimmed.starts_with("<!-- plan-issue-record:")
2009                && !trimmed.starts_with("<!-- execute-from-tracking-issue:")
2010        })
2011        .map(str::to_string)
2012        .collect::<Vec<_>>();
2013    let Some(execution_heading) = stripped
2014        .iter()
2015        .position(|line| line.trim() == "## Execution State")
2016    else {
2017        return Err("execution-state markdown is missing `## Execution State`".to_string());
2018    };
2019
2020    let mut out = stripped
2021        .into_iter()
2022        .skip(execution_heading + 1)
2023        .filter(|line| !line.trim().starts_with("- Profile:"))
2024        .collect::<Vec<_>>();
2025    while out.first().is_some_and(|line| line.trim().is_empty()) {
2026        out.remove(0);
2027    }
2028    let normalized = finalize_markdown(out).trim().to_string();
2029    if normalized.is_empty() {
2030        return Err("execution-state markdown has no visible state content".to_string());
2031    }
2032    Ok(normalized)
2033}
2034
2035/// Shared terminal-status contract for closeout gates; must stay aligned with
2036/// the close-ready terminal set in `execute.rs`.
2037fn is_terminal_task_status(status: TaskRowStatus) -> bool {
2038    matches!(
2039        status,
2040        TaskRowStatus::Done | TaskRowStatus::Deferred | TaskRowStatus::Waived
2041    )
2042}
2043
2044fn is_terminal_state(state: &StateData) -> bool {
2045    state.status == Some(StateStatus::Complete)
2046        && state
2047            .tasks
2048            .iter()
2049            .all(|task| is_terminal_task_status(task.status))
2050}
2051
2052fn render_state_payload_visible(state: &StateData) -> String {
2053    let view = StateVisibleView {
2054        status: state.status.map(status_state_label),
2055        target_scope: state
2056            .target_scope
2057            .as_deref()
2058            .filter(|value| !value.is_empty()),
2059        current: state.current.as_deref().filter(|value| !value.is_empty()),
2060        next_action: state
2061            .next_action
2062            .as_deref()
2063            .filter(|value| !value.is_empty()),
2064        tasks: state
2065            .tasks
2066            .iter()
2067            .map(|task| StateTaskRow {
2068                id: table_cell(&task.id),
2069                status: task_row_status_label(task.status),
2070                title: table_cell(task.title.as_deref().unwrap_or("")),
2071            })
2072            .collect(),
2073    };
2074    let mut engine = Engine::builder().build();
2075    engine
2076        .register_template(STATE_VISIBLE_TEMPLATE_NAME, STATE_VISIBLE_TEMPLATE)
2077        .expect("state template registers");
2078    let rendered = engine
2079        .render(STATE_VISIBLE_TEMPLATE_NAME, &view)
2080        .expect("state template renders");
2081    rendered.trim().to_string()
2082}
2083
2084/// Rebuild a normalized execution-state body with its header bullets derived
2085/// from the payload, keeping every `## ` section (Task Ledger, Validation Plan,
2086/// …) from the authored markdown. The input must already be normalized (marker
2087/// and `- Profile:` lines stripped, header starting at the top). When the
2088/// payload yields no header bullets the authored body is returned unchanged so
2089/// we never drop all visible content.
2090fn replace_state_header_from_payload(markdown: &str, state: &StateData) -> String {
2091    let header = render_state_header_lines_from_payload(state);
2092    if header.is_empty() {
2093        return markdown.to_string();
2094    }
2095    let lines: Vec<&str> = markdown.lines().collect();
2096    let first_section = lines
2097        .iter()
2098        .position(|line| line.trim_start().starts_with("## "));
2099    let mut out = header;
2100    if let Some(idx) = first_section {
2101        out.push(String::new());
2102        out.extend(lines[idx..].iter().map(|line| (*line).to_string()));
2103    }
2104    finalize_markdown(out).trim().to_string()
2105}
2106
2107/// Render the canonical Execution State header bullets (`Status` / `Target
2108/// scope` / `Current task` / `Next task`) from the payload, omitting any field
2109/// that is absent or empty.
2110fn render_state_header_lines_from_payload(state: &StateData) -> Vec<String> {
2111    let mut lines = Vec::new();
2112    if let Some(status) = state.status.map(status_state_label) {
2113        lines.push(format!("- Status: {status}"));
2114    }
2115    if let Some(scope) = state
2116        .target_scope
2117        .as_deref()
2118        .filter(|value| !value.is_empty())
2119    {
2120        lines.push(format!("- Target scope: {scope}"));
2121    }
2122    if let Some(current) = state.current.as_deref().filter(|value| !value.is_empty()) {
2123        lines.push(format!("- Current task: {current}"));
2124    }
2125    if let Some(next) = state
2126        .next_action
2127        .as_deref()
2128        .filter(|value| !value.is_empty())
2129    {
2130        lines.push(format!("- Next task: {next}"));
2131    }
2132    lines
2133}
2134
2135fn render_session_payload_visible(session: &SessionData, raw: &Value) -> String {
2136    let extras: Vec<KeyValuePair> = raw
2137        .as_object()
2138        .map(|object| {
2139            object
2140                .iter()
2141                .filter(|(key, value)| {
2142                    !matches!(key.as_str(), "summary" | "highlights" | "links") && !value.is_null()
2143                })
2144                .map(|(key, value)| KeyValuePair {
2145                    key: key.trim().to_string(),
2146                    value: visible_value(value),
2147                })
2148                .collect()
2149        })
2150        .unwrap_or_default();
2151
2152    let view = SessionVisibleView {
2153        summary: session.summary.trim(),
2154        highlights: session
2155            .highlights
2156            .iter()
2157            .map(|item| item.trim().to_string())
2158            .collect(),
2159        links: session
2160            .links
2161            .iter()
2162            .map(|(key, value)| KeyValuePair {
2163                key: key.trim().to_string(),
2164                value: value.trim().to_string(),
2165            })
2166            .collect(),
2167        extras,
2168    };
2169    let mut engine = Engine::builder().build();
2170    engine
2171        .register_template(SESSION_VISIBLE_TEMPLATE_NAME, SESSION_VISIBLE_TEMPLATE)
2172        .expect("session template registers");
2173    let rendered = engine
2174        .render(SESSION_VISIBLE_TEMPLATE_NAME, &view)
2175        .expect("session template renders");
2176    rendered.trim().to_string()
2177}
2178
2179fn render_validation_payload_visible(validation: &ValidationData) -> String {
2180    let view = ValidationVisibleView {
2181        overall: validation_overall_label(validation.overall),
2182        commands: validation
2183            .commands
2184            .iter()
2185            .map(|command| ValidationCommandRow {
2186                command: table_cell(&command.command),
2187                status: validation_command_status_label(command.status),
2188                evidence: table_cell(command.evidence.as_deref().unwrap_or("")),
2189                _phantom: std::marker::PhantomData,
2190            })
2191            .collect(),
2192        waivers: validation
2193            .waivers
2194            .iter()
2195            .map(|waiver| ValidationWaiverRow {
2196                command: waiver.command.trim(),
2197                reason: waiver.reason.trim(),
2198            })
2199            .collect(),
2200    };
2201    let mut engine = Engine::builder().build();
2202    engine
2203        .register_template(
2204            VALIDATION_VISIBLE_TEMPLATE_NAME,
2205            VALIDATION_VISIBLE_TEMPLATE,
2206        )
2207        .expect("validation template registers");
2208    let rendered = engine
2209        .render(VALIDATION_VISIBLE_TEMPLATE_NAME, &view)
2210        .expect("validation template renders");
2211    rendered.trim().to_string()
2212}
2213
2214fn render_review_payload_visible(review: &ReviewData) -> String {
2215    let view = ReviewVisibleView {
2216        decision: review_decision_label(review.decision),
2217        lenses: if review.lenses.is_empty() {
2218            None
2219        } else {
2220            Some(review.lenses.join(", "))
2221        },
2222        outcome_comment_url: review
2223            .outcome_comment_url
2224            .as_deref()
2225            .map(str::trim)
2226            .filter(|value| !value.is_empty()),
2227        findings: review
2228            .findings
2229            .iter()
2230            .map(|finding| ReviewFindingRow {
2231                id: table_cell(&finding.id),
2232                severity: finding_severity_label(finding.severity),
2233                disposition: finding_disposition_label(finding.disposition),
2234                summary: table_cell(&finding.summary),
2235            })
2236            .collect(),
2237    };
2238    let mut engine = Engine::builder().build();
2239    engine
2240        .register_template(REVIEW_VISIBLE_TEMPLATE_NAME, REVIEW_VISIBLE_TEMPLATE)
2241        .expect("review template registers");
2242    let rendered = engine
2243        .render(REVIEW_VISIBLE_TEMPLATE_NAME, &view)
2244        .expect("review template renders");
2245    rendered.trim().to_string()
2246}
2247
2248fn render_closeout_payload_visible(closeout: &CloseoutData) -> String {
2249    let override_block = closeout
2250        .non_required_check_override
2251        .as_ref()
2252        .filter(|value| !value.is_null());
2253    let override_reason = override_block.and_then(|block| {
2254        block
2255            .get("reason")
2256            .and_then(Value::as_str)
2257            .map(str::trim)
2258            .filter(|value| !value.is_empty())
2259            .map(str::to_string)
2260    });
2261    let override_failures = override_block.and_then(|block| {
2262        let items = block
2263            .get("observed_non_required_failures")
2264            .and_then(Value::as_array)
2265            .filter(|items| !items.is_empty())?;
2266        Some(
2267            items
2268                .iter()
2269                .map(visible_value)
2270                .filter(|value| !value.trim().is_empty())
2271                .collect::<Vec<_>>()
2272                .join(", "),
2273        )
2274    });
2275    let has_override = override_block.is_some();
2276
2277    let view = CloseoutVisibleView {
2278        final_status: closeout.final_status.trim(),
2279        approver: closeout
2280            .approval
2281            .approver
2282            .as_deref()
2283            .map(str::trim)
2284            .filter(|value| !value.is_empty()),
2285        approval_url: closeout
2286            .approval
2287            .comment_url
2288            .as_deref()
2289            .map(str::trim)
2290            .filter(|value| !value.is_empty()),
2291        final_validation_url: closeout
2292            .final_validation_url
2293            .as_deref()
2294            .map(str::trim)
2295            .filter(|value| !value.is_empty()),
2296        notes: closeout
2297            .notes
2298            .as_deref()
2299            .map(str::trim)
2300            .filter(|value| !value.is_empty()),
2301        has_override,
2302        override_reason,
2303        override_failures,
2304        linked_prs: closeout
2305            .linked_prs
2306            .iter()
2307            .map(|pr| {
2308                let pr_label = pr.url.as_deref().unwrap_or(&pr.pr_ref);
2309                let required_label = required_check_label(pr.required_state, pr.required_count);
2310                CloseoutPrRow {
2311                    label: table_cell(pr_label),
2312                    merge_sha: table_cell(pr.merge_sha.as_deref().unwrap_or("")),
2313                    checks: check_status_label(pr.checks),
2314                    required: table_cell(&required_label),
2315                    non_required_failures: table_cell(&non_empty_join(
2316                        &pr.non_required_failures,
2317                        "none",
2318                    )),
2319                }
2320            })
2321            .collect(),
2322    };
2323    let mut engine = Engine::builder().build();
2324    engine
2325        .register_template(CLOSEOUT_VISIBLE_TEMPLATE_NAME, CLOSEOUT_VISIBLE_TEMPLATE)
2326        .expect("closeout template registers");
2327    let rendered = engine
2328        .render(CLOSEOUT_VISIBLE_TEMPLATE_NAME, &view)
2329        .expect("closeout template renders");
2330    rendered.trim().to_string()
2331}
2332
2333fn task_row_status_label(status: TaskRowStatus) -> &'static str {
2334    match status {
2335        TaskRowStatus::Pending => "pending",
2336        TaskRowStatus::InProgress => "in-progress",
2337        TaskRowStatus::Done => "done",
2338        TaskRowStatus::Deferred => "deferred",
2339        TaskRowStatus::Blocked => "blocked",
2340        TaskRowStatus::Waived => "waived",
2341    }
2342}
2343
2344fn validation_command_status_label(status: ValidationCommandStatus) -> &'static str {
2345    match status {
2346        ValidationCommandStatus::Pass => "pass",
2347        ValidationCommandStatus::Fail => "fail",
2348        ValidationCommandStatus::Skipped => "skipped",
2349    }
2350}
2351
2352fn finding_severity_label(severity: FindingSeverity) -> &'static str {
2353    match severity {
2354        FindingSeverity::Blocker => "blocker",
2355        FindingSeverity::Major => "major",
2356        FindingSeverity::Minor => "minor",
2357        FindingSeverity::Nit => "nit",
2358    }
2359}
2360
2361fn finding_disposition_label(disposition: FindingDisposition) -> &'static str {
2362    match disposition {
2363        FindingDisposition::Fixed => "fixed",
2364        FindingDisposition::Residual => "residual",
2365        FindingDisposition::FollowUp => "follow-up",
2366        FindingDisposition::Deferred => "deferred",
2367        FindingDisposition::NoAction => "no-action",
2368    }
2369}
2370
2371fn check_status_label(status: CheckStatus) -> &'static str {
2372    match status {
2373        CheckStatus::Pass => "pass",
2374        CheckStatus::Fail => "fail",
2375        CheckStatus::None => "none",
2376    }
2377}
2378
2379/// Render the closeout-comment `Required` column from the
2380/// `(required_state, required_count)` pair on a [`LinkedPrEvidence`].
2381///
2382/// Five label branches:
2383///
2384/// - `Some(Pass) + Some(0)` → `"none required"` — no required-check
2385///   rule exists for the branch (or rule explicitly declares zero
2386///   required checks). The earlier rendering collapsed this into
2387///   `"unknown"` even on healthy PRs (sympoies/nils-cli#541 closeout).
2388/// - `Some(Pass) + Some(N>=1)` → `"pass (N)"` — required checks
2389///   enforced and green.
2390/// - `Some(Pass) + None` → `"pass"` — required-state known but count
2391///   not surfaced by the provider; defensive case kept for future
2392///   adapters.
2393/// - `Some(Fail) + …` → `"fail (N)"` or `"fail"` — required checks
2394///   enforced and at least one is red. Non-required failures are
2395///   carried in the adjacent column.
2396/// - `Some(None) + …` → `"none"` — provider reported no aggregate
2397///   rollup at all (e.g. PR #554 on #541's closeout, where GHA never
2398///   registered any check suite).
2399/// - `None + …` → `"unknown"` — adapter probe failed (e.g. `gh` spawn
2400///   error, `gh pr checks --required` non-zero with unrecognised
2401///   stderr, fixture omits the field). Kept as the catch-all so a
2402///   future probe regression remains visible.
2403fn required_check_label(state: Option<CheckStatus>, count: Option<u32>) -> String {
2404    match (state, count) {
2405        (Some(CheckStatus::Pass), Some(0)) => "none required".to_string(),
2406        (Some(CheckStatus::Pass), Some(n)) => format!("pass ({n})"),
2407        (Some(CheckStatus::Pass), None) => "pass".to_string(),
2408        (Some(CheckStatus::Fail), Some(n)) => format!("fail ({n})"),
2409        (Some(CheckStatus::Fail), None) => "fail".to_string(),
2410        (Some(CheckStatus::None), _) => "none".to_string(),
2411        (None, _) => "unknown".to_string(),
2412    }
2413}
2414
2415fn table_cell(value: &str) -> String {
2416    value.trim().replace('|', "\\|").replace('\n', "<br>")
2417}
2418
2419fn visible_value(value: &Value) -> String {
2420    match value {
2421        Value::String(text) => text.trim().to_string(),
2422        Value::Array(items) => items
2423            .iter()
2424            .map(visible_value)
2425            .collect::<Vec<_>>()
2426            .join(", "),
2427        Value::Object(_) => value.to_string(),
2428        Value::Null => String::new(),
2429        _ => value.to_string(),
2430    }
2431}
2432
2433// -----------------------------------------------------------------------------
2434// Strict closeout gate for `record close`.
2435// -----------------------------------------------------------------------------
2436
2437#[derive(Debug, Clone, Serialize)]
2438pub struct StrictCloseoutGateResult {
2439    pub ready: bool,
2440    pub checks: Vec<CloseoutCheck>,
2441    /// Stable machine-readable codes for blocked items, one per failure.
2442    pub blocked_codes: Vec<String>,
2443}
2444
2445#[derive(Debug, Clone)]
2446pub struct StrictCloseoutGateInput<'a> {
2447    pub profile: RecordProfile,
2448    pub approval: Option<&'a str>,
2449    /// Provider-verified linked PR evidence. Each entry must carry a
2450    /// `merge_sha`; missing merge_sha is treated as `linked-pr-not-merged`.
2451    pub linked_prs: &'a [LinkedPrEvidence],
2452    /// Current issue body. When paired with `expected_dashboard`, the gate
2453    /// fails with `dashboard-out-of-date` if the recomputed dashboard does
2454    /// not appear in the body.
2455    pub current_body: Option<&'a str>,
2456    pub expected_dashboard: Option<&'a str>,
2457    /// When `true`, the linked-PR branch skips the conservative
2458    /// "unknown required-check state with aggregate failure" check
2459    /// and lets the gate pass on non-required failures alone. The
2460    /// caller is responsible for surfacing the override decision in
2461    /// closeout-comment evidence; the gate itself does not record it.
2462    pub allow_non_required_check_failure: bool,
2463}
2464
2465pub fn evaluate_strict_closeout_gate(
2466    audit: &RecordAudit,
2467    input: StrictCloseoutGateInput<'_>,
2468) -> StrictCloseoutGateResult {
2469    let mut checks = Vec::new();
2470    let mut blocked_codes: Vec<String> = Vec::new();
2471
2472    let push_pass = |checks: &mut Vec<CloseoutCheck>, check: &str, detail: String| {
2473        checks.push(CloseoutCheck {
2474            check: check.to_string(),
2475            status: "pass".to_string(),
2476            detail,
2477        });
2478    };
2479    let push_fail = |checks: &mut Vec<CloseoutCheck>,
2480                     blocked: &mut Vec<String>,
2481                     check: &str,
2482                     detail: String,
2483                     code: &str| {
2484        checks.push(CloseoutCheck {
2485            check: check.to_string(),
2486            status: "fail".to_string(),
2487            detail,
2488        });
2489        blocked.push(code.to_string());
2490    };
2491
2492    for (role, label, code) in [
2493        ("source", "source snapshot", "source-missing"),
2494        ("plan", "plan snapshot", "plan-missing"),
2495    ] {
2496        if audit.evidence.contains_key(role) {
2497            push_pass(&mut checks, label, "present".to_string());
2498        } else {
2499            push_fail(
2500                &mut checks,
2501                &mut blocked_codes,
2502                label,
2503                "missing".to_string(),
2504                code,
2505            );
2506        }
2507    }
2508
2509    match audit.evidence.get("state") {
2510        Some(hit) => {
2511            let status = hit.status.as_deref();
2512            let parsed = hit
2513                .payload
2514                .as_ref()
2515                .and_then(|payload| payload.parse_state().ok());
2516            match status {
2517                Some(value) if value.eq_ignore_ascii_case("complete") => {
2518                    let tasks_incomplete = parsed
2519                        .as_ref()
2520                        .map(|data| {
2521                            data.tasks
2522                                .iter()
2523                                .any(|task| !is_terminal_task_status(task.status))
2524                        })
2525                        .unwrap_or(false);
2526                    if tasks_incomplete {
2527                        push_fail(
2528                            &mut checks,
2529                            &mut blocked_codes,
2530                            "execution state",
2531                            "complete but tasks are not all done/deferred/waived".to_string(),
2532                            "state-tasks-incomplete",
2533                        );
2534                    } else {
2535                        push_pass(&mut checks, "execution state", "complete".to_string());
2536                    }
2537                }
2538                Some(value) => push_fail(
2539                    &mut checks,
2540                    &mut blocked_codes,
2541                    "execution state",
2542                    format!("latest state status is `{value}`"),
2543                    "state-not-complete",
2544                ),
2545                None => push_fail(
2546                    &mut checks,
2547                    &mut blocked_codes,
2548                    "execution state",
2549                    "missing payload status".to_string(),
2550                    "state-not-complete",
2551                ),
2552            }
2553        }
2554        None => push_fail(
2555            &mut checks,
2556            &mut blocked_codes,
2557            "execution state",
2558            "missing".to_string(),
2559            "state-missing",
2560        ),
2561    }
2562
2563    match audit.evidence.get("session") {
2564        Some(hit) => push_pass(
2565            &mut checks,
2566            "execution session",
2567            hit.url.as_deref().unwrap_or("present").to_string(),
2568        ),
2569        None => push_fail(
2570            &mut checks,
2571            &mut blocked_codes,
2572            "execution session",
2573            "missing role=session lifecycle record".to_string(),
2574            "session-missing",
2575        ),
2576    }
2577
2578    match audit.evidence.get("validation") {
2579        Some(hit) => match hit.status.as_deref() {
2580            Some("pass") => push_pass(&mut checks, "validation", "pass".to_string()),
2581            Some(value) => push_fail(
2582                &mut checks,
2583                &mut blocked_codes,
2584                "validation",
2585                format!("latest validation overall = `{value}`"),
2586                "validation-failed",
2587            ),
2588            None => push_fail(
2589                &mut checks,
2590                &mut blocked_codes,
2591                "validation",
2592                "missing payload status".to_string(),
2593                "validation-failed",
2594            ),
2595        },
2596        None => push_fail(
2597            &mut checks,
2598            &mut blocked_codes,
2599            "validation",
2600            "missing".to_string(),
2601            "validation-missing",
2602        ),
2603    }
2604
2605    match audit.evidence.get("review") {
2606        Some(hit) => {
2607            let parsed = hit.payload.as_ref().map(|payload| payload.parse_review());
2608            match parsed {
2609                Some(Ok(data)) => match data.decision {
2610                    ReviewDecision::RequestChanges => push_fail(
2611                        &mut checks,
2612                        &mut blocked_codes,
2613                        "review",
2614                        "decision = request-changes".to_string(),
2615                        "review-rejected",
2616                    ),
2617                    decision => {
2618                        let unresolved = data.findings.iter().any(|finding| {
2619                            matches!(finding.disposition, FindingDisposition::Residual)
2620                                && matches!(
2621                                    finding.severity,
2622                                    FindingSeverity::Blocker | FindingSeverity::Major
2623                                )
2624                        });
2625                        if unresolved {
2626                            push_fail(
2627                                &mut checks,
2628                                &mut blocked_codes,
2629                                "review",
2630                                "unresolved blocker/major findings".to_string(),
2631                                "review-unresolved-findings",
2632                            );
2633                        } else {
2634                            let label = match decision {
2635                                ReviewDecision::Approve => "approve",
2636                                ReviewDecision::CommentsOnly => "comments-only",
2637                                ReviewDecision::RequestChanges => unreachable!(),
2638                            };
2639                            push_pass(&mut checks, "review", format!("decision = {label}"));
2640                        }
2641                    }
2642                },
2643                Some(Err(err)) => push_fail(
2644                    &mut checks,
2645                    &mut blocked_codes,
2646                    "review",
2647                    format!("malformed review payload: {}", err.message),
2648                    "review-rejected",
2649                ),
2650                None => push_fail(
2651                    &mut checks,
2652                    &mut blocked_codes,
2653                    "review",
2654                    "missing payload".to_string(),
2655                    "review-missing",
2656                ),
2657            }
2658        }
2659        None => push_fail(
2660            &mut checks,
2661            &mut blocked_codes,
2662            "review",
2663            "missing".to_string(),
2664            "review-missing",
2665        ),
2666    }
2667
2668    let approval_text = input.approval.unwrap_or("").trim();
2669    if approval_text.is_empty() {
2670        push_fail(
2671            &mut checks,
2672            &mut blocked_codes,
2673            "close approval",
2674            "missing explicit approval".to_string(),
2675            "approval-missing",
2676        );
2677    } else {
2678        push_pass(&mut checks, "close approval", approval_text.to_string());
2679    }
2680
2681    if input.linked_prs.is_empty() {
2682        push_pass(&mut checks, "linked PRs", "none provided".to_string());
2683    } else {
2684        let mut unmerged: Vec<String> = Vec::new();
2685        let mut required_failed: Vec<String> = Vec::new();
2686        for pr in input.linked_prs {
2687            let sha = pr.merge_sha.as_deref().map(str::trim).unwrap_or("");
2688            if sha.is_empty() {
2689                unmerged.push(format!("{} (no merge_sha)", pr.pr_ref));
2690                continue;
2691            }
2692            match pr.required_state {
2693                Some(CheckStatus::Fail) => {
2694                    required_failed.push(format!("{} (required checks failed)", pr.pr_ref));
2695                }
2696                Some(CheckStatus::Pass | CheckStatus::None) => {
2697                    // Required checks resolved cleanly (including the
2698                    // `required_count == 0` case). Non-required failures
2699                    // are informational only and never block.
2700                }
2701                None => {
2702                    // Provider could not classify required-vs-non-required
2703                    // (e.g. GitLab today, or a degraded `gh` call). Stay
2704                    // conservative: aggregate failure blocks unless the
2705                    // caller has set the explicit override flag.
2706                    if matches!(pr.checks, CheckStatus::Fail)
2707                        && !input.allow_non_required_check_failure
2708                    {
2709                        required_failed.push(format!(
2710                            "{} (checks={:?}; required-state unknown)",
2711                            pr.pr_ref, pr.checks
2712                        ));
2713                    }
2714                }
2715            }
2716        }
2717        if !unmerged.is_empty() {
2718            push_fail(
2719                &mut checks,
2720                &mut blocked_codes,
2721                "linked PRs",
2722                unmerged.join(", "),
2723                "linked-pr-not-merged",
2724            );
2725        }
2726        if !required_failed.is_empty() {
2727            push_fail(
2728                &mut checks,
2729                &mut blocked_codes,
2730                "linked PRs required checks",
2731                required_failed.join(", "),
2732                "linked-pr-checks-failed",
2733            );
2734        }
2735        if unmerged.is_empty() && required_failed.is_empty() {
2736            push_pass(
2737                &mut checks,
2738                "linked PRs",
2739                format!("{} merged", input.linked_prs.len()),
2740            );
2741        }
2742    }
2743
2744    if let (Some(current), Some(expected)) = (input.current_body, input.expected_dashboard) {
2745        let current_norm = normalize_for_dashboard_compare(current);
2746        let expected_norm = normalize_for_dashboard_compare(expected);
2747        if current_norm.contains(&expected_norm) {
2748            push_pass(&mut checks, "dashboard", "matches canonical".to_string());
2749        } else {
2750            push_fail(
2751                &mut checks,
2752                &mut blocked_codes,
2753                "dashboard",
2754                "dashboard differs from recomputed canonical".to_string(),
2755                "dashboard-out-of-date",
2756            );
2757        }
2758    }
2759
2760    let ready = checks.iter().all(|check| check.status == "pass");
2761    StrictCloseoutGateResult {
2762        ready,
2763        checks,
2764        blocked_codes,
2765    }
2766}
2767
2768fn normalize_for_dashboard_compare(text: &str) -> String {
2769    text.lines()
2770        .map(str::trim_end)
2771        .collect::<Vec<_>>()
2772        .join("\n")
2773}
2774
2775#[cfg(test)]
2776mod sprint3_tests {
2777    use super::*;
2778    use serde_json::json;
2779
2780    fn build_audit_with_evidence(comments: Vec<(serde_json::Value, &str)>) -> RecordAudit {
2781        let payload = json!({
2782            "comments": comments
2783                .into_iter()
2784                .map(|(body, url)| json!({"body": body, "url": url, "created_at": "2026-05-23T08:00:00Z"}))
2785                .collect::<Vec<_>>()
2786        });
2787        audit_record(None, &payload.to_string(), None).expect("audit ok")
2788    }
2789
2790    fn v2_body(role: &str, data: Value) -> Value {
2791        let envelope = json!({
2792            "schema": PAYLOAD_SCHEMA_V2,
2793            "role": role,
2794            "profile": "tracking",
2795            "data": data,
2796        });
2797        let payload_json = serde_json::to_string(&envelope).expect("serialize");
2798        json!(format!(
2799            "<!-- plan-issue-record:v2 role={role} profile=tracking -->\n\n```{PAYLOAD_FENCE_INFO}\n{payload_json}\n```\n",
2800        ))
2801    }
2802
2803    #[test]
2804    fn audit_treats_v2_marker_without_payload_fence_as_payload_none() {
2805        // Reproduces [F11] deferred follow-up: v2 marker with no payload
2806        // fence should leave evidence.payload = None instead of erroring.
2807        let body_only_marker = json!(
2808            "<!-- plan-issue-record:v2 role=session profile=tracking -->\n\n## Execution Session\n\nfreeform notes, no payload\n"
2809        );
2810        let audit = build_audit_with_evidence(vec![(
2811            body_only_marker,
2812            "https://github.com/owner/repo/issues/1#issuecomment-session",
2813        )]);
2814        let session = audit
2815            .evidence
2816            .get("session")
2817            .expect("session evidence registered");
2818        assert!(session.payload.is_none(), "payload should be None");
2819        assert_eq!(audit.recognized_count, 1);
2820    }
2821
2822    #[test]
2823    fn audit_strict_fails_on_malformed_payload() {
2824        // [F11] deferred: malformed payload fence must error rather than
2825        // silently degrade to payload=None.
2826        let body = json!(
2827            "<!-- plan-issue-record:v2 role=state profile=tracking -->\n\n```plan-issue-record-payload\n{not valid json\n```\n"
2828        );
2829        let payload = json!({
2830            "comments": [{
2831                "body": body,
2832                "url": "https://github.com/owner/repo/issues/1#issuecomment-bad",
2833                "created_at": "2026-05-23T08:00:00Z",
2834            }]
2835        });
2836        let err = audit_record(None, &payload.to_string(), None)
2837            .expect_err("malformed payload should fail audit");
2838        assert!(
2839            err.contains("malformed payload"),
2840            "error should mention malformed payload: {err}"
2841        );
2842    }
2843
2844    #[test]
2845    fn strict_gate_passes_when_all_v2_evidence_complete_and_merged() {
2846        let state = v2_body(
2847            "state",
2848            json!({
2849                "status": "complete",
2850                "target_scope": "scope",
2851                "tasks": [
2852                    {"id": "1.1", "status": "done", "title": "x"},
2853                    {"id": "1.2", "status": "deferred", "title": "y"},
2854                ],
2855                "prs": [{"ref": "owner/repo#1", "url": "u", "status": "merged"}],
2856                "blockers": [],
2857                "links": {},
2858            }),
2859        );
2860        let validation = v2_body(
2861            "validation",
2862            json!({"overall": "pass", "commands": [], "waivers": []}),
2863        );
2864        let review = v2_body(
2865            "review",
2866            json!({
2867                "decision": "approve",
2868                "lenses": ["testing"],
2869                "findings": [],
2870            }),
2871        );
2872        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2873        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2874        let session = v2_body("session", json!({"summary": "session complete"}));
2875        let audit = build_audit_with_evidence(vec![
2876            (source, "u-src"),
2877            (plan, "u-plan"),
2878            (state, "u-state"),
2879            (session, "u-session"),
2880            (validation, "u-val"),
2881            (review, "u-rev"),
2882        ]);
2883
2884        let linked_prs = vec![LinkedPrEvidence {
2885            pr_ref: "owner/repo#1".to_string(),
2886            url: Some("https://github.com/owner/repo/pull/1".to_string()),
2887            merge_sha: Some("abcdef1234567890".to_string()),
2888            checks: CheckStatus::Pass,
2889            required_state: Some(CheckStatus::Pass),
2890            required_count: Some(1),
2891            non_required_failures: Vec::new(),
2892        }];
2893        let result = evaluate_strict_closeout_gate(
2894            &audit,
2895            StrictCloseoutGateInput {
2896                profile: RecordProfile::Tracking,
2897                approval: Some("https://github.com/owner/repo/issues/1#issuecomment-9"),
2898                linked_prs: &linked_prs,
2899                current_body: None,
2900                expected_dashboard: None,
2901                allow_non_required_check_failure: false,
2902            },
2903        );
2904        assert!(result.ready, "gate should pass: {:?}", result.checks);
2905        assert!(result.blocked_codes.is_empty());
2906    }
2907
2908    #[test]
2909    fn strict_gate_passes_when_state_tasks_include_waived() {
2910        // Reproduces plan-tracking-testbed#65: close-ready and
2911        // `is_terminal_state` already treat `waived` as terminal, so the
2912        // strict record-close gate must accept it too instead of blocking
2913        // with `state-tasks-incomplete`.
2914        let state = v2_body(
2915            "state",
2916            json!({
2917                "status": "complete",
2918                "target_scope": "scope",
2919                "tasks": [
2920                    {"id": "1.1", "status": "done", "title": "x"},
2921                    {"id": "1.2", "status": "waived", "title": "y"},
2922                ],
2923                "prs": [{"ref": "owner/repo#1", "url": "u", "status": "merged"}],
2924                "blockers": [],
2925                "links": {},
2926            }),
2927        );
2928        let validation = v2_body(
2929            "validation",
2930            json!({"overall": "pass", "commands": [], "waivers": []}),
2931        );
2932        let review = v2_body(
2933            "review",
2934            json!({
2935                "decision": "approve",
2936                "lenses": ["testing"],
2937                "findings": [],
2938            }),
2939        );
2940        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2941        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2942        let session = v2_body("session", json!({"summary": "session complete"}));
2943        let audit = build_audit_with_evidence(vec![
2944            (source, "u-src"),
2945            (plan, "u-plan"),
2946            (state, "u-state"),
2947            (session, "u-session"),
2948            (validation, "u-val"),
2949            (review, "u-rev"),
2950        ]);
2951
2952        let linked_prs = vec![LinkedPrEvidence {
2953            pr_ref: "owner/repo#1".to_string(),
2954            url: Some("https://github.com/owner/repo/pull/1".to_string()),
2955            merge_sha: Some("abcdef1234567890".to_string()),
2956            checks: CheckStatus::Pass,
2957            required_state: Some(CheckStatus::Pass),
2958            required_count: Some(1),
2959            non_required_failures: Vec::new(),
2960        }];
2961        let result = evaluate_strict_closeout_gate(
2962            &audit,
2963            StrictCloseoutGateInput {
2964                profile: RecordProfile::Tracking,
2965                approval: Some("https://github.com/owner/repo/issues/1#issuecomment-9"),
2966                linked_prs: &linked_prs,
2967                current_body: None,
2968                expected_dashboard: None,
2969                allow_non_required_check_failure: false,
2970            },
2971        );
2972        assert!(
2973            result.ready,
2974            "waived task row should be terminal: {:?}",
2975            result.checks
2976        );
2977        assert!(result.blocked_codes.is_empty());
2978    }
2979
2980    #[test]
2981    fn strict_gate_blocks_when_state_not_complete() {
2982        let state = v2_body(
2983            "state",
2984            json!({"status": "in-progress", "target_scope": "s", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2985        );
2986        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2987        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2988        let session = v2_body("session", json!({"summary": "session complete"}));
2989        let validation = v2_body("validation", json!({"overall": "pass"}));
2990        let review = v2_body("review", json!({"decision": "approve"}));
2991        let audit = build_audit_with_evidence(vec![
2992            (source, "a"),
2993            (plan, "b"),
2994            (state, "c"),
2995            (session, "d"),
2996            (validation, "d"),
2997            (review, "e"),
2998        ]);
2999        let result = evaluate_strict_closeout_gate(
3000            &audit,
3001            StrictCloseoutGateInput {
3002                profile: RecordProfile::Tracking,
3003                approval: Some("ok"),
3004                linked_prs: &[],
3005                current_body: None,
3006                expected_dashboard: None,
3007                allow_non_required_check_failure: false,
3008            },
3009        );
3010        assert!(!result.ready);
3011        assert!(
3012            result
3013                .blocked_codes
3014                .iter()
3015                .any(|c| c == "state-not-complete"),
3016            "{:?}",
3017            result.blocked_codes
3018        );
3019    }
3020
3021    #[test]
3022    fn strict_gate_blocks_when_review_rejected_or_unresolved() {
3023        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3024        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3025        let state = v2_body(
3026            "state",
3027            json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3028        );
3029        let session = v2_body("session", json!({"summary": "session complete"}));
3030        let validation = v2_body("validation", json!({"overall": "pass"}));
3031        let review_rejected = v2_body("review", json!({"decision": "request-changes"}));
3032        let audit_rej = build_audit_with_evidence(vec![
3033            (source.clone(), "a"),
3034            (plan.clone(), "b"),
3035            (state.clone(), "c"),
3036            (session.clone(), "d"),
3037            (validation.clone(), "d"),
3038            (review_rejected, "e"),
3039        ]);
3040        let res_rej = evaluate_strict_closeout_gate(
3041            &audit_rej,
3042            StrictCloseoutGateInput {
3043                profile: RecordProfile::Tracking,
3044                approval: Some("ok"),
3045                linked_prs: &[],
3046                current_body: None,
3047                expected_dashboard: None,
3048                allow_non_required_check_failure: false,
3049            },
3050        );
3051        assert!(res_rej.blocked_codes.iter().any(|c| c == "review-rejected"));
3052
3053        let review_unresolved = v2_body(
3054            "review",
3055            json!({
3056                "decision": "approve",
3057                "findings": [
3058                    {"id": "F1", "severity": "blocker", "disposition": "residual", "summary": "x"}
3059                ]
3060            }),
3061        );
3062        let audit_un = build_audit_with_evidence(vec![
3063            (source, "a"),
3064            (plan, "b"),
3065            (state, "c"),
3066            (session, "d"),
3067            (validation, "d"),
3068            (review_unresolved, "e"),
3069        ]);
3070        let res_un = evaluate_strict_closeout_gate(
3071            &audit_un,
3072            StrictCloseoutGateInput {
3073                profile: RecordProfile::Tracking,
3074                approval: Some("ok"),
3075                linked_prs: &[],
3076                current_body: None,
3077                expected_dashboard: None,
3078                allow_non_required_check_failure: false,
3079            },
3080        );
3081        assert!(
3082            res_un
3083                .blocked_codes
3084                .iter()
3085                .any(|c| c == "review-unresolved-findings")
3086        );
3087    }
3088
3089    #[test]
3090    fn strict_gate_blocks_when_linked_pr_missing_merge_sha() {
3091        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3092        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3093        let state = v2_body(
3094            "state",
3095            json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3096        );
3097        let session = v2_body("session", json!({"summary": "session complete"}));
3098        let validation = v2_body("validation", json!({"overall": "pass"}));
3099        let review = v2_body("review", json!({"decision": "approve"}));
3100        let audit = build_audit_with_evidence(vec![
3101            (source, "a"),
3102            (plan, "b"),
3103            (state, "c"),
3104            (session, "d"),
3105            (validation, "d"),
3106            (review, "e"),
3107        ]);
3108        let linked = vec![LinkedPrEvidence {
3109            pr_ref: "owner/repo#1".to_string(),
3110            url: None,
3111            merge_sha: None,
3112            checks: CheckStatus::Pass,
3113            required_state: Some(CheckStatus::Pass),
3114            required_count: Some(0),
3115            non_required_failures: Vec::new(),
3116        }];
3117        let res = evaluate_strict_closeout_gate(
3118            &audit,
3119            StrictCloseoutGateInput {
3120                profile: RecordProfile::Tracking,
3121                approval: Some("ok"),
3122                linked_prs: &linked,
3123                current_body: None,
3124                expected_dashboard: None,
3125                allow_non_required_check_failure: false,
3126            },
3127        );
3128        assert!(
3129            res.blocked_codes
3130                .iter()
3131                .any(|c| c == "linked-pr-not-merged")
3132        );
3133    }
3134
3135    #[test]
3136    fn strict_gate_passes_with_non_required_failure_when_required_pass() {
3137        // Regression for sympoies/nils-cli#502: a non-required check
3138        // failure with required-state success must not block the gate.
3139        let audit = build_audit_with_evidence(vec![
3140            (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3141            (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3142            (
3143                v2_body(
3144                    "state",
3145                    json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3146                ),
3147                "c",
3148            ),
3149            (
3150                v2_body("session", json!({"summary": "session complete"})),
3151                "d",
3152            ),
3153            (v2_body("validation", json!({"overall": "pass"})), "d"),
3154            (v2_body("review", json!({"decision": "approve"})), "e"),
3155        ]);
3156        let linked = vec![LinkedPrEvidence {
3157            pr_ref: "owner/repo#1".to_string(),
3158            url: None,
3159            merge_sha: Some("abc".to_string()),
3160            checks: CheckStatus::Fail,
3161            required_state: Some(CheckStatus::Pass),
3162            required_count: Some(0),
3163            non_required_failures: vec!["scripts/ci/all.sh".to_string()],
3164        }];
3165        let res = evaluate_strict_closeout_gate(
3166            &audit,
3167            StrictCloseoutGateInput {
3168                profile: RecordProfile::Tracking,
3169                approval: Some("ok"),
3170                linked_prs: &linked,
3171                current_body: None,
3172                expected_dashboard: None,
3173                allow_non_required_check_failure: false,
3174            },
3175        );
3176        assert!(res.ready, "blocked: {:?}", res.blocked_codes);
3177        assert!(res.blocked_codes.is_empty(), "{:?}", res.blocked_codes);
3178    }
3179
3180    #[test]
3181    fn strict_gate_emits_linked_pr_checks_failed_when_required_fail() {
3182        let audit = build_audit_with_evidence(vec![
3183            (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3184            (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3185            (
3186                v2_body(
3187                    "state",
3188                    json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3189                ),
3190                "c",
3191            ),
3192            (
3193                v2_body("session", json!({"summary": "session complete"})),
3194                "d",
3195            ),
3196            (v2_body("validation", json!({"overall": "pass"})), "d"),
3197            (v2_body("review", json!({"decision": "approve"})), "e"),
3198        ]);
3199        let linked = vec![LinkedPrEvidence {
3200            pr_ref: "owner/repo#1".to_string(),
3201            url: None,
3202            merge_sha: Some("abc".to_string()),
3203            checks: CheckStatus::Fail,
3204            required_state: Some(CheckStatus::Fail),
3205            required_count: Some(2),
3206            non_required_failures: Vec::new(),
3207        }];
3208        let res = evaluate_strict_closeout_gate(
3209            &audit,
3210            StrictCloseoutGateInput {
3211                profile: RecordProfile::Tracking,
3212                approval: Some("ok"),
3213                linked_prs: &linked,
3214                current_body: None,
3215                expected_dashboard: None,
3216                allow_non_required_check_failure: false,
3217            },
3218        );
3219        assert!(
3220            res.blocked_codes
3221                .iter()
3222                .any(|c| c == "linked-pr-checks-failed"),
3223            "expected linked-pr-checks-failed, got {:?}",
3224            res.blocked_codes
3225        );
3226        assert!(
3227            !res.blocked_codes
3228                .iter()
3229                .any(|c| c == "linked-pr-not-merged"),
3230            "must not collapse into linked-pr-not-merged"
3231        );
3232    }
3233
3234    #[test]
3235    fn strict_gate_override_unblocks_unknown_required_state_aggregate_fail() {
3236        let audit = build_audit_with_evidence(vec![
3237            (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3238            (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3239            (
3240                v2_body(
3241                    "state",
3242                    json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3243                ),
3244                "c",
3245            ),
3246            (
3247                v2_body("session", json!({"summary": "session complete"})),
3248                "d",
3249            ),
3250            (v2_body("validation", json!({"overall": "pass"})), "d"),
3251            (v2_body("review", json!({"decision": "approve"})), "e"),
3252        ]);
3253        let linked = vec![LinkedPrEvidence {
3254            pr_ref: "owner/repo#1".to_string(),
3255            url: None,
3256            merge_sha: Some("abc".to_string()),
3257            checks: CheckStatus::Fail,
3258            required_state: None,
3259            required_count: None,
3260            non_required_failures: vec!["opt-in/lint".to_string()],
3261        }];
3262
3263        let blocked = evaluate_strict_closeout_gate(
3264            &audit,
3265            StrictCloseoutGateInput {
3266                profile: RecordProfile::Tracking,
3267                approval: Some("ok"),
3268                linked_prs: &linked,
3269                current_body: None,
3270                expected_dashboard: None,
3271                allow_non_required_check_failure: false,
3272            },
3273        );
3274        assert!(
3275            blocked
3276                .blocked_codes
3277                .iter()
3278                .any(|c| c == "linked-pr-checks-failed"),
3279            "conservative path blocks: {:?}",
3280            blocked.blocked_codes
3281        );
3282
3283        let unblocked = evaluate_strict_closeout_gate(
3284            &audit,
3285            StrictCloseoutGateInput {
3286                profile: RecordProfile::Tracking,
3287                approval: Some("ok"),
3288                linked_prs: &linked,
3289                current_body: None,
3290                expected_dashboard: None,
3291                allow_non_required_check_failure: true,
3292            },
3293        );
3294        assert!(unblocked.ready, "{:?}", unblocked.blocked_codes);
3295    }
3296
3297    #[test]
3298    fn strict_gate_blocks_when_approval_empty() {
3299        let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3300        let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3301        let state = v2_body(
3302            "state",
3303            json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3304        );
3305        let session = v2_body("session", json!({"summary": "session complete"}));
3306        let validation = v2_body("validation", json!({"overall": "pass"}));
3307        let review = v2_body("review", json!({"decision": "approve"}));
3308        let audit = build_audit_with_evidence(vec![
3309            (source, "a"),
3310            (plan, "b"),
3311            (state, "c"),
3312            (session, "d"),
3313            (validation, "d"),
3314            (review, "e"),
3315        ]);
3316        let res = evaluate_strict_closeout_gate(
3317            &audit,
3318            StrictCloseoutGateInput {
3319                profile: RecordProfile::Tracking,
3320                approval: Some("   "),
3321                linked_prs: &[],
3322                current_body: None,
3323                expected_dashboard: None,
3324                allow_non_required_check_failure: false,
3325            },
3326        );
3327        assert!(res.blocked_codes.iter().any(|c| c == "approval-missing"));
3328    }
3329
3330    #[test]
3331    fn render_record_post_comment_emits_marker_and_hidden_payload_carrier() {
3332        let body = render_record_post_comment(
3333            RecordProfile::Tracking,
3334            LifecycleCommentKind::State,
3335            json!({"status": "complete", "tasks": [], "prs": []}),
3336            Some("session summary"),
3337            Some("2026-05-23T08:42:11Z"),
3338        )
3339        .expect("render");
3340        assert!(
3341            body.starts_with("<!-- plan-issue-record:v2 role=state profile=tracking -->"),
3342            "{body}"
3343        );
3344        assert!(
3345            !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3346            "{body}"
3347        );
3348        assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3349        let payload = extract_payload(&body).expect("payload");
3350        assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3351        assert_eq!(payload.role, PayloadRole::State);
3352        assert!(body.contains("session summary"), "{body}");
3353    }
3354
3355    fn state_summary_with_task_ledger() -> &'static str {
3356        "## Execution State\n\n\
3357         - Status: in-progress\n\n\
3358         ## Task Ledger\n\n\
3359         | ID | Status | Task |\n\
3360         | --- | --- | --- |\n\
3361         | 1.1 | pending | Demo task |\n"
3362    }
3363
3364    fn render_state_with_display(display: TaskLedgerDisplay) -> String {
3365        render_record_post_comment_with_display(
3366            RecordProfile::Tracking,
3367            LifecycleCommentKind::State,
3368            json!({
3369                "status": "in-progress",
3370                "tasks": [{"id": "1.1", "status": "pending", "title": "Demo task"}],
3371                "prs": [],
3372                "blockers": [],
3373                "links": {}
3374            }),
3375            None,
3376            Some(state_summary_with_task_ledger()),
3377            None,
3378            display,
3379        )
3380        .expect("render")
3381    }
3382
3383    #[test]
3384    fn task_ledger_display_open_emits_open_fold() {
3385        let body = render_state_with_display(TaskLedgerDisplay::Open);
3386        assert!(body.contains("<details open>"), "{body}");
3387        assert!(
3388            body.contains("<summary>Show task ledger</summary>"),
3389            "{body}"
3390        );
3391        assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3392    }
3393
3394    #[test]
3395    fn task_ledger_display_collapsed_emits_closed_fold() {
3396        let body = render_state_with_display(TaskLedgerDisplay::Collapsed);
3397        assert!(body.contains("<details>"), "{body}");
3398        assert!(!body.contains("<details open>"), "{body}");
3399    }
3400
3401    #[test]
3402    fn task_ledger_display_expanded_emits_no_fold() {
3403        let body = render_state_with_display(TaskLedgerDisplay::Expanded);
3404        assert!(!body.contains("<details"), "{body}");
3405        assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3406    }
3407
3408    #[test]
3409    fn render_record_post_comment_synthesizes_validation_review_and_closeout() {
3410        let validation = render_record_post_comment(
3411            RecordProfile::Tracking,
3412            LifecycleCommentKind::Validation,
3413            json!({
3414                "overall": "pass",
3415                "commands": [{"command": "cargo test", "status": "pass", "evidence": "ok"}],
3416                "waivers": []
3417            }),
3418            None,
3419            None,
3420        )
3421        .expect("validation render");
3422        assert!(validation.contains("- Overall: pass"), "{validation}");
3423        assert!(
3424            validation.contains("| cargo test | pass | ok |"),
3425            "{validation}"
3426        );
3427        assert!(validation.contains(PAYLOAD_COMMENT_PREFIX), "{validation}");
3428
3429        let review = render_record_post_comment(
3430            RecordProfile::Tracking,
3431            LifecycleCommentKind::Review,
3432            json!({
3433                "decision": "approve",
3434                "lenses": ["testing", "maintainability"],
3435                "findings": [{
3436                    "id": "F1",
3437                    "severity": "minor",
3438                    "disposition": "fixed",
3439                    "summary": "covered"
3440                }],
3441                "outcome_comment_url": "https://example.test/review"
3442            }),
3443            None,
3444            None,
3445        )
3446        .expect("review render");
3447        assert!(review.contains("- Decision: approve"), "{review}");
3448        assert!(
3449            review.contains("- Lenses: testing, maintainability"),
3450            "{review}"
3451        );
3452        assert!(
3453            review.contains("| F1 | minor | fixed | covered |"),
3454            "{review}"
3455        );
3456
3457        let closeout = render_record_post_comment(
3458            RecordProfile::Tracking,
3459            LifecycleCommentKind::Closeout,
3460            json!({
3461                "final_status": "complete",
3462                "approval": {"comment_url": "https://example.test/approval"},
3463                "linked_prs": [{
3464                    "ref": "owner/repo#1",
3465                    "url": "https://example.test/pr/1",
3466                    "merge_sha": "abc123",
3467                    "checks": "pass",
3468                    "required_state": "pass",
3469                    "required_count": 2,
3470                    "non_required_failures": []
3471                }],
3472                "non_required_check_override": {
3473                    "reason": "operator accepted non-required lint",
3474                    "observed_non_required_failures": ["owner/repo#1: opt-in/lint"]
3475                },
3476                "notes": "closed"
3477            }),
3478            Some("Closeout summary."),
3479            None,
3480        )
3481        .expect("closeout render");
3482        assert!(closeout.contains("Closeout summary."), "{closeout}");
3483        assert!(closeout.contains("- Final status: complete"), "{closeout}");
3484        assert!(
3485            closeout.contains("| https://example.test/pr/1 | abc123 | pass | pass (2) | none |"),
3486            "{closeout}"
3487        );
3488        assert!(
3489            closeout.contains("- Reason: operator accepted non-required lint"),
3490            "{closeout}"
3491        );
3492        assert!(
3493            closeout.contains("- Observed failures: owner/repo#1: opt-in/lint"),
3494            "{closeout}"
3495        );
3496
3497        let no_pr_closeout = render_record_post_comment(
3498            RecordProfile::Tracking,
3499            LifecycleCommentKind::Closeout,
3500            json!({
3501                "final_status": "complete",
3502                "approval": {"comment_url": "https://example.test/approval"},
3503                "linked_prs": [],
3504                "notes": "closed without linked PR"
3505            }),
3506            Some("Closeout summary."),
3507            None,
3508        )
3509        .expect("closeout render");
3510        assert!(
3511            no_pr_closeout.contains("- Linked PRs: none"),
3512            "{no_pr_closeout}"
3513        );
3514    }
3515
3516    #[test]
3517    fn render_record_post_comment_rejects_source_or_plan() {
3518        let err = render_record_post_comment(
3519            RecordProfile::Tracking,
3520            LifecycleCommentKind::Source,
3521            json!({}),
3522            None,
3523            None,
3524        )
3525        .expect_err("must reject source");
3526        assert!(err.contains("source"), "{err}");
3527    }
3528
3529    #[test]
3530    fn render_record_snapshot_comment_includes_details_and_hidden_payload() {
3531        let snapshot = SnapshotData {
3532            path: "docs/plans/sample/sample-plan.md".to_string(),
3533            commit: "abc1234".to_string(),
3534            title: Some("Sample Plan".to_string()),
3535            summary: Some("One-liner".to_string()),
3536        };
3537        let body = render_record_snapshot_comment(
3538            RecordProfile::Tracking,
3539            LifecycleCommentKind::Plan,
3540            &snapshot,
3541            "# Sample Plan\n\nbody...\n",
3542            Some("2026-05-23T08:42:11Z"),
3543        )
3544        .expect("render");
3545        assert!(
3546            body.contains("- Path: `docs/plans/sample/sample-plan.md`"),
3547            "{body}"
3548        );
3549        assert!(body.contains("- Commit: `abc1234`"), "{body}");
3550        assert!(body.contains("- Summary: One-liner"), "{body}");
3551        assert!(body.contains("<details>"), "{body}");
3552        assert!(
3553            !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3554            "{body}"
3555        );
3556        assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3557        let payload = extract_payload(&body).expect("payload");
3558        assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3559        assert_eq!(payload.role, PayloadRole::Plan);
3560    }
3561
3562    #[test]
3563    fn extract_payload_ignores_payload_markers_inside_snapshot_details() {
3564        let nested_payload = RecordPayload {
3565            schema: PAYLOAD_SCHEMA_V2.to_string(),
3566            role: PayloadRole::State,
3567            profile: PayloadProfile::Tracking,
3568            updated_at: None,
3569            data: json!({"status": "complete"}),
3570        };
3571        let nested_carrier = render_payload_carrier(&nested_payload).expect("nested carrier");
3572        let snapshot = SnapshotData {
3573            path: "docs/plans/sample/sample-discussion-source.md".to_string(),
3574            commit: "abc1234".to_string(),
3575            title: None,
3576            summary: None,
3577        };
3578        let body = render_record_snapshot_comment(
3579            RecordProfile::Tracking,
3580            LifecycleCommentKind::Source,
3581            &snapshot,
3582            &format!(
3583                "# Source\n\n{nested_carrier}\n\n```{PAYLOAD_FENCE_INFO}\n{{not valid json}}\n```\n"
3584            ),
3585            None,
3586        )
3587        .expect("render");
3588
3589        let payload = extract_payload(&body).expect("payload");
3590        assert_eq!(payload.role, PayloadRole::Source);
3591    }
3592
3593    #[test]
3594    fn required_check_label_emits_five_distinct_branches() {
3595        // `Some(Pass) + Some(0)` is the "no required-check rule" case
3596        // observed on sympoies/nils-cli#541's closeout — was previously
3597        // collapsed into "unknown".
3598        assert_eq!(
3599            required_check_label(Some(CheckStatus::Pass), Some(0)),
3600            "none required"
3601        );
3602
3603        // `Some(Pass) + Some(N>=1)` keeps the existing "pass (N)" shape.
3604        assert_eq!(
3605            required_check_label(Some(CheckStatus::Pass), Some(3)),
3606            "pass (3)"
3607        );
3608
3609        // `Some(Pass) + None` is the defensive case for adapters that
3610        // know the state but not the count.
3611        assert_eq!(required_check_label(Some(CheckStatus::Pass), None), "pass");
3612
3613        // `Some(Fail) + Some(N)` keeps the existing "fail (N)" shape.
3614        assert_eq!(
3615            required_check_label(Some(CheckStatus::Fail), Some(2)),
3616            "fail (2)"
3617        );
3618        assert_eq!(required_check_label(Some(CheckStatus::Fail), None), "fail");
3619
3620        // `Some(None)` is the aggregate-rollup-absent case (PR #554 on
3621        // #541's closeout — GHA never registered any check suite).
3622        assert_eq!(
3623            required_check_label(Some(CheckStatus::None), Some(0)),
3624            "none"
3625        );
3626        assert_eq!(required_check_label(Some(CheckStatus::None), None), "none");
3627
3628        // `None` is the catch-all for probe failures / fixture omissions.
3629        assert_eq!(required_check_label(None, None), "unknown");
3630        assert_eq!(required_check_label(None, Some(0)), "unknown");
3631    }
3632
3633    // Snapshot tests below lock the full byte-for-byte wire shape of
3634    // `render_record_post_comment` for each lifecycle kind. The existing
3635    // `contains` assertions cover individual fields; these goldens guard
3636    // against silent template/serializer drift that re-orders lines, drops
3637    // sections, or grows a new field downstream consumers don't expect.
3638
3639    fn golden_dump(label: &str, body: &str) {
3640        if std::env::var("LIFECYCLE_RECORD_GOLDEN_DUMP").is_ok() {
3641            eprintln!("--- BEGIN {label} ---\n{body}\n--- END {label} ---");
3642        }
3643    }
3644
3645    #[test]
3646    fn golden_state_post_comment_locks_full_wire_shape() {
3647        let body = render_record_post_comment(
3648            RecordProfile::Tracking,
3649            LifecycleCommentKind::State,
3650            json!({
3651                "status": "complete",
3652                "target_scope": "PR #599 follow-ups",
3653                "current": "delivering snapshot tests",
3654                "next_action": "open closeout comment",
3655                "tasks": [
3656                    {"id": "1.1", "status": "done", "title": "ship URL parser"},
3657                    {"id": "1.2", "status": "in-progress", "title": "ship snapshots"},
3658                ],
3659                "prs": [{"ref": "owner/repo#1", "url": "https://example.test/pr/1", "status": "merged"}],
3660                "blockers": [],
3661                "links": {},
3662            }),
3663            None,
3664            Some("2026-05-23T08:42:11Z"),
3665        )
3666        .expect("state render");
3667        golden_dump("state", &body);
3668        assert_eq!(
3669            body,
3670            include_str!("snapshots/state_post_comment.md"),
3671            "state post-comment shape drifted; run with LIFECYCLE_RECORD_GOLDEN_DUMP=1 to dump"
3672        );
3673    }
3674
3675    #[test]
3676    fn golden_validation_post_comment_locks_full_wire_shape() {
3677        let body = render_record_post_comment(
3678            RecordProfile::Tracking,
3679            LifecycleCommentKind::Validation,
3680            json!({
3681                "overall": "pass",
3682                "commands": [
3683                    {"command": "cargo test --workspace", "status": "pass", "evidence": "all green"},
3684                    {"command": "scripts/ci/local-fast.sh", "status": "pass", "evidence": "ok"},
3685                ],
3686                "waivers": [],
3687            }),
3688            None,
3689            Some("2026-05-23T08:42:11Z"),
3690        )
3691        .expect("validation render");
3692        golden_dump("validation", &body);
3693        assert_eq!(
3694            body,
3695            include_str!("snapshots/validation_post_comment.md"),
3696            "validation post-comment shape drifted"
3697        );
3698    }
3699
3700    #[test]
3701    fn golden_review_post_comment_locks_full_wire_shape() {
3702        let body = render_record_post_comment(
3703            RecordProfile::Tracking,
3704            LifecycleCommentKind::Review,
3705            json!({
3706                "decision": "approve",
3707                "lenses": ["testing", "maintainability"],
3708                "findings": [
3709                    {"id": "F1", "severity": "minor", "disposition": "fixed", "summary": "covered"},
3710                ],
3711                "outcome_comment_url": "https://example.test/review",
3712            }),
3713            None,
3714            Some("2026-05-23T08:42:11Z"),
3715        )
3716        .expect("review render");
3717        golden_dump("review", &body);
3718        assert_eq!(
3719            body,
3720            include_str!("snapshots/review_post_comment.md"),
3721            "review post-comment shape drifted"
3722        );
3723    }
3724
3725    #[test]
3726    fn golden_closeout_post_comment_locks_full_wire_shape() {
3727        let body = render_record_post_comment(
3728            RecordProfile::Tracking,
3729            LifecycleCommentKind::Closeout,
3730            json!({
3731                "final_status": "complete",
3732                "approval": {"comment_url": "https://example.test/approval"},
3733                "linked_prs": [{
3734                    "ref": "owner/repo#1",
3735                    "url": "https://example.test/pr/1",
3736                    "merge_sha": "abc1234",
3737                    "checks": "pass",
3738                    "required_state": "pass",
3739                    "required_count": 2,
3740                    "non_required_failures": []
3741                }],
3742                "notes": "shipped"
3743            }),
3744            Some("Closeout summary."),
3745            Some("2026-05-23T08:42:11Z"),
3746        )
3747        .expect("closeout render");
3748        golden_dump("closeout", &body);
3749        assert_eq!(
3750            body,
3751            include_str!("snapshots/closeout_post_comment.md"),
3752            "closeout post-comment shape drifted"
3753        );
3754    }
3755}